Compare commits
87 Commits
harden/aut
...
8c5acb626f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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`.
|
||||
|
||||
BIN
public/logo-4ventures.png
Normal file
BIN
public/logo-4ventures.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
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);
|
||||
});
|
||||
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())
|
||||
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())
|
||||
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))
|
||||
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);
|
||||
});
|
||||
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.';
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
257
src/app/api/admin/tenants/users/route.ts
Normal file
257
src/app/api/admin/tenants/users/route.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createClient as createServiceClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
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 key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!key) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
const admin = createServiceClient(supabaseUrl(), key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
return { admin, headers: serviceHeaders()! };
|
||||
}
|
||||
|
||||
function tenantOf(u: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}): string | null {
|
||||
return (
|
||||
(u.app_metadata?.tenant_id as string) ||
|
||||
(u.user_metadata?.tenant_id as string) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** GET ?tenantId= — users with access to this client portal. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
};
|
||||
|
||||
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const users: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastSignIn: string | null;
|
||||
}[] = [];
|
||||
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({
|
||||
page,
|
||||
perPage: 200,
|
||||
});
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
for (const u of data.users) {
|
||||
if (tenantOf(u) !== tenantId) continue;
|
||||
users.push({
|
||||
id: u.id,
|
||||
email: u.email || '',
|
||||
role:
|
||||
(u.app_metadata?.role as string) ||
|
||||
(u.user_metadata?.role as string) ||
|
||||
'client',
|
||||
createdAt: u.created_at,
|
||||
lastSignIn: u.last_sign_in_at || null,
|
||||
});
|
||||
}
|
||||
if (data.users.length < 200) break;
|
||||
page += 1;
|
||||
if (page > 20) break;
|
||||
}
|
||||
|
||||
users.sort((a, b) => a.email.localeCompare(b.email));
|
||||
return NextResponse.json({ users });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — invite email to client portal view.
|
||||
* Body: { tenantId, email, companyName? }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin, headers } = gate as Awaited<
|
||||
ReturnType<typeof requireAgency>
|
||||
> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
const email =
|
||||
typeof body.email === 'string' ? body.email.toLowerCase().trim() : '';
|
||||
if (!tenantId || !email || !email.includes('@')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y email válidos requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let company =
|
||||
typeof body.companyName === 'string' ? body.companyName.trim() : '';
|
||||
if (!company) {
|
||||
const tr = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=name`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (tr.ok) {
|
||||
const rows = await tr.json();
|
||||
company = rows[0]?.name || 'Cliente';
|
||||
} else {
|
||||
company = 'Cliente';
|
||||
}
|
||||
}
|
||||
|
||||
const site =
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://app.4ventures.es';
|
||||
|
||||
const { data: invited, error: inviteErr } =
|
||||
await admin.auth.admin.inviteUserByEmail(email, {
|
||||
redirectTo: `${site}/login`,
|
||||
data: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
|
||||
if (inviteErr) {
|
||||
// Fallback: create user with temp password if invite not configured
|
||||
if (
|
||||
inviteErr.message.toLowerCase().includes('sending') ||
|
||||
inviteErr.message.toLowerCase().includes('smtp') ||
|
||||
inviteErr.message.toLowerCase().includes('email')
|
||||
) {
|
||||
const temp =
|
||||
typeof body.password === 'string' && body.password.length >= 8
|
||||
? body.password
|
||||
: `4V-${Math.random().toString(36).slice(2, 10)}!aA1`;
|
||||
const { data: created, error: createErr } =
|
||||
await admin.auth.admin.createUser({
|
||||
email,
|
||||
password: temp,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
app_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
if (createErr) {
|
||||
return NextResponse.json(
|
||||
{ error: createErr.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: created.user?.id,
|
||||
email,
|
||||
role: 'client',
|
||||
},
|
||||
invited: false,
|
||||
temporaryPassword: temp,
|
||||
hint: 'SMTP no configurado: se creó usuario con contraseña temporal. Compártela al cliente.',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ error: inviteErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const uid = invited.user?.id;
|
||||
if (uid) {
|
||||
await admin.auth.admin.updateUserById(uid, {
|
||||
app_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
user_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: uid, email, role: 'client' },
|
||||
invited: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE — remove portal access (delete auth user). Body: { tenantId, userId } */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
|
||||
if (!tenantId || !userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y userId requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await admin.auth.admin.getUserById(userId);
|
||||
if (error || !data.user) {
|
||||
return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 });
|
||||
}
|
||||
if (tenantOf(data.user) !== tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'El usuario no pertenece a este cliente' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const del = await admin.auth.admin.deleteUser(userId);
|
||||
if (del.error) {
|
||||
return NextResponse.json({ error: del.error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
135
src/app/api/assets/collab/route.ts
Normal file
135
src/app/api/assets/collab/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
dualStatus,
|
||||
insertComment,
|
||||
listApprovals,
|
||||
listComments,
|
||||
partyFromUser,
|
||||
upsertApproval,
|
||||
} from '@/lib/content-collab';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function gate() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 }),
|
||||
};
|
||||
}
|
||||
return { user, ctx, party: partyFromUser(user) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx, party } = g as {
|
||||
ctx: { tenantId: string };
|
||||
party: 'agency' | 'client';
|
||||
};
|
||||
const ids = (req.nextUrl.searchParams.get('ids') || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const resourceId = req.nextUrl.searchParams.get('resourceId');
|
||||
const approvals = await listApprovals(ctx.tenantId, 'asset', ids);
|
||||
const dual: Record<string, ReturnType<typeof dualStatus>> = {};
|
||||
for (const id of ids) dual[id] = dualStatus(approvals[id] || []);
|
||||
let comments = null;
|
||||
if (resourceId) {
|
||||
comments = await listComments(ctx.tenantId, 'asset', resourceId);
|
||||
}
|
||||
return NextResponse.json({ dual, comments, party });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { user, ctx, party } = g as {
|
||||
user: { id: string; email?: string };
|
||||
ctx: { tenantId: string };
|
||||
party: 'agency' | 'client';
|
||||
};
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const assetId = String(body.assetId || '');
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: 'assetId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'approve' || body.action === 'revoke') {
|
||||
await upsertApproval({
|
||||
tenantId: ctx.tenantId,
|
||||
resourceType: 'asset',
|
||||
resourceId: assetId,
|
||||
party,
|
||||
approved: body.action === 'approve',
|
||||
actorId: user.id,
|
||||
actorEmail: user.email || null,
|
||||
});
|
||||
const approvals = await listApprovals(ctx.tenantId, 'asset', [assetId]);
|
||||
const dual = dualStatus(approvals[assetId] || []);
|
||||
|
||||
// Mirror to asset_files columns when both OK
|
||||
const headers = serviceHeaders();
|
||||
if (headers) {
|
||||
const patch: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (party === 'agency') {
|
||||
patch.agency_approved_at =
|
||||
body.action === 'approve' ? new Date().toISOString() : null;
|
||||
patch.agency_approved_by = body.action === 'approve' ? user.id : null;
|
||||
} else {
|
||||
patch.client_approved_at =
|
||||
body.action === 'approve' ? new Date().toISOString() : null;
|
||||
patch.client_approved_by = body.action === 'approve' ? user.id : null;
|
||||
}
|
||||
if (dual.both) patch.review_status = 'validated';
|
||||
else patch.review_status = 'in_review';
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetId)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, dual, party });
|
||||
}
|
||||
|
||||
if (body.action === 'comment') {
|
||||
const text = String(body.body || '').trim();
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 });
|
||||
}
|
||||
const row = await insertComment({
|
||||
tenantId: ctx.tenantId,
|
||||
resourceType: 'asset',
|
||||
resourceId: assetId,
|
||||
body: text.slice(0, 4000),
|
||||
authorId: user.id,
|
||||
authorEmail: user.email || null,
|
||||
authorParty: party,
|
||||
});
|
||||
return NextResponse.json({ ok: true, comment: row });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'action inválida' }, { status: 400 });
|
||||
}
|
||||
77
src/app/api/assets/download/route.ts
Normal file
77
src/app/api/assets/download/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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 { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET ?id= — signed download URL (60s) */
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const svc = serviceSupabase();
|
||||
if (!headers || !svc) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
// Agency can download any tenant's file when in context; verify tenant match
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&select=id,tenant_id,storage_path,file_name,mime_type`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const rows = await r.json();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
if (!agency && row.tenant_id !== ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
if (agency && row.tenant_id !== ctx.tenantId) {
|
||||
return NextResponse.json({ error: 'Cambia al tenant del archivo' }, { status: 403 });
|
||||
}
|
||||
|
||||
const signed = await svc.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.createSignedUrl(row.storage_path, 120);
|
||||
if (signed.error || !signed.data?.signedUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: signed.error?.message || 'No se pudo firmar URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: signed.data.signedUrl,
|
||||
fileName: row.file_name,
|
||||
mimeType: row.mime_type,
|
||||
});
|
||||
}
|
||||
148
src/app/api/assets/drive/route.ts
Normal file
148
src/app/api/assets/drive/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
findClientesRootFolder,
|
||||
getDriveDownloadUrl,
|
||||
getDriveFileMeta,
|
||||
getTenantDriveFolderId,
|
||||
isUnderDriveFolder,
|
||||
listFilesInFolder,
|
||||
} from '@/lib/google-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
||||
|
||||
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */
|
||||
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: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
void isAgencyRole(userRole(user));
|
||||
|
||||
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
||||
if (downloadId) {
|
||||
try {
|
||||
const link = await getTenantDriveFolderId(ctx.tenantId);
|
||||
if (!link) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Sin carpeta Drive del cliente' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const ok = await isUnderDriveFolder(downloadId, link.folderId);
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Archivo fuera de la carpeta del cliente' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
const url = await getDriveDownloadUrl(downloadId);
|
||||
return NextResponse.json({ url });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'download failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const link = await getTenantDriveFolderId(ctx.tenantId);
|
||||
if (!link) {
|
||||
return NextResponse.json({
|
||||
files: [],
|
||||
linked: false,
|
||||
warning:
|
||||
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
|
||||
});
|
||||
}
|
||||
|
||||
// Soft check: linked folder should live under «Clientes»
|
||||
let scopeWarning: string | null = null;
|
||||
try {
|
||||
const clientes = await findClientesRootFolder();
|
||||
if (clientes) {
|
||||
const ok = await isUnderDriveFolder(link.folderId, clientes.id);
|
||||
if (!ok) {
|
||||
scopeWarning = `La carpeta enlazada («${link.folderName || link.folderId}») no está dentro de «Clientes». Rematch en Agencia.`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
||||
let folderId = link.folderId;
|
||||
|
||||
if (requested && requested !== link.folderId) {
|
||||
const allowed = await isUnderDriveFolder(requested, link.folderId);
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Carpeta fuera del árbol del cliente. Solo se permite navegar dentro de su carpeta en Clientes.',
|
||||
rootFolderId: link.folderId,
|
||||
folderName: link.folderName,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
folderId = requested;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentMeta = await getDriveFileMeta(folderId);
|
||||
const files = await listFilesInFolder(folderId);
|
||||
const mapped = files
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
bytes: f.size ? Number(f.size) : null,
|
||||
modifiedTime: f.modifiedTime,
|
||||
webViewLink: f.webViewLink,
|
||||
isFolder: f.mimeType === FOLDER_MIME,
|
||||
source: 'drive' as const,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, 'es');
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
linked: true,
|
||||
rootFolderId: link.folderId,
|
||||
rootFolderName: link.folderName,
|
||||
folderId,
|
||||
folderName: currentMeta?.name || link.folderName,
|
||||
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
|
||||
files: mapped,
|
||||
warning: scopeWarning,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json({
|
||||
linked: true,
|
||||
rootFolderId: link.folderId,
|
||||
rootFolderName: link.folderName,
|
||||
folderId,
|
||||
folderName: link.folderName,
|
||||
files: [],
|
||||
warning: scopeWarning || (e instanceof Error ? e.message : 'Drive error'),
|
||||
});
|
||||
}
|
||||
}
|
||||
274
src/app/api/assets/route.ts
Normal file
274
src/app/api/assets/route.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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 {
|
||||
ASSETS_BUCKET,
|
||||
MAX_ASSET_BYTES,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
sanitizeFolder,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import {
|
||||
ensureChildFolder,
|
||||
getTenantDriveFolderId,
|
||||
uploadToDrive,
|
||||
} from '@/lib/google-drive';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function gate() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (ctx.portfolioWide || !ctx.tenantId) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto antes de gestionar activos.' },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
return { user, ctx, agency: isAgencyRole(userRole(user)) };
|
||||
}
|
||||
|
||||
/** GET — list approved files for active tenant */
|
||||
export async function GET() {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string };
|
||||
agency: boolean;
|
||||
};
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&status=eq.approved&select=*&order=created_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
const msg = await r.text();
|
||||
return NextResponse.json({
|
||||
assets: [],
|
||||
warning: msg.includes('does not exist')
|
||||
? 'Aplicar sql/asset_files.sql'
|
||||
: msg.slice(0, 200),
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ assets: await r.json() });
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('asset_files')
|
||||
.select('*')
|
||||
.eq('status', 'approved')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) {
|
||||
return NextResponse.json({
|
||||
assets: [],
|
||||
warning: error.message,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ assets: data ?? [] });
|
||||
}
|
||||
|
||||
/** POST multipart: files[] + optional folder */
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { user, ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
user: { id: string };
|
||||
ctx: { tenantId: string };
|
||||
};
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Storage bucket: ${bucketOk.error}` },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
const headers = serviceHeaders();
|
||||
if (!svc || !headers) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Falta SUPABASE_SERVICE_ROLE_KEY' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const folder = sanitizeFolder(String(form.get('folder') || ''));
|
||||
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
||||
|
||||
if (!files.length) {
|
||||
return NextResponse.json({ error: 'Ningún archivo' }, { status: 400 });
|
||||
}
|
||||
|
||||
const uploaded: unknown[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size <= 0) {
|
||||
errors.push(`${file.name}: vacío`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_ASSET_BYTES) {
|
||||
errors.push(`${file.name}: supera 80 MB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const safeName = sanitizeFileName(file.name);
|
||||
const id = randomUUID();
|
||||
const pathParts = [ctx.tenantId!, folder, `${id}_${safeName}`].filter(
|
||||
Boolean
|
||||
);
|
||||
const storagePath = pathParts.join('/');
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const sha256 = createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
upsert: false,
|
||||
});
|
||||
if (up.error) {
|
||||
errors.push(`${file.name}: ${up.error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const row = {
|
||||
id,
|
||||
tenant_id: ctx.tenantId,
|
||||
title: safeName,
|
||||
folder,
|
||||
file_name: safeName,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
bytes: file.size,
|
||||
storage_path: storagePath,
|
||||
sha256,
|
||||
status: 'approved',
|
||||
created_by: user.id,
|
||||
};
|
||||
|
||||
const ins = await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
errors.push(`${file.name}: ${await ins.text()}`);
|
||||
continue;
|
||||
}
|
||||
const created = await ins.json();
|
||||
const assetRow = Array.isArray(created) ? created[0] : created;
|
||||
|
||||
// Best-effort mirror to linked Google Drive folder
|
||||
try {
|
||||
const link = await getTenantDriveFolderId(ctx.tenantId!);
|
||||
if (link) {
|
||||
let parentId = link.folderId;
|
||||
if (folder) {
|
||||
const parts = folder.split('/').filter(Boolean);
|
||||
for (const part of parts) {
|
||||
parentId = await ensureChildFolder(parentId, part);
|
||||
}
|
||||
}
|
||||
const driveFile = await uploadToDrive({
|
||||
parentFolderId: parentId,
|
||||
fileName: safeName,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
buffer: buf,
|
||||
});
|
||||
if (driveFile.id && assetRow?.id) {
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(assetRow.id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ drive_file_id: driveFile.id }),
|
||||
}
|
||||
);
|
||||
assetRow.drive_file_id = driveFile.id;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`${file.name}: portal ok, Drive: ${e instanceof Error ? e.message : 'error'}`
|
||||
);
|
||||
}
|
||||
|
||||
uploaded.push(assetRow);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ uploaded, errors },
|
||||
{ status: uploaded.length ? 201 : 400 }
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE ?id= */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string };
|
||||
};
|
||||
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim();
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const svc = serviceSupabase();
|
||||
if (!headers || !svc) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const get = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId!)}&select=id,storage_path`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!get.ok) {
|
||||
return NextResponse.json({ error: await get.text() }, { status: 500 });
|
||||
}
|
||||
const rows = await get.json();
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: 'No encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([row.storage_path]);
|
||||
const del = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/asset_files?id=eq.${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
if (!del.ok) {
|
||||
return NextResponse.json({ error: await del.text() }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
97
src/app/api/branding/logo/route.ts
Normal file
97
src/app/api/branding/logo/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
VIEW_TENANT_COOKIE,
|
||||
} from '@/lib/agency';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET ?tenantId= — stream client logo (agency any tenant; client only own).
|
||||
* logo_url may be https://… or storage:{path}.
|
||||
*/
|
||||
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 agency = isAgencyRole(userRole(user));
|
||||
const cookieStore = await cookies();
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
const paramId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
const ownId = homeTenantId(user);
|
||||
|
||||
let tenantId = paramId || viewId || ownId || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'sin tenant' }, { status: 400 });
|
||||
}
|
||||
if (!agency && tenantId !== ownId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const tr = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=logo_url`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tr.ok) {
|
||||
return NextResponse.json({ error: 'tenant' }, { status: 404 });
|
||||
}
|
||||
const rows = await tr.json();
|
||||
const logoUrl: string | null = rows[0]?.logo_url || null;
|
||||
if (!logoUrl) {
|
||||
return NextResponse.json({ error: 'sin logo' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) {
|
||||
return NextResponse.redirect(logoUrl);
|
||||
}
|
||||
|
||||
const path = logoUrl.startsWith('storage:')
|
||||
? logoUrl.slice('storage:'.length)
|
||||
: logoUrl;
|
||||
|
||||
const sb = serviceSupabase();
|
||||
if (!sb) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
const { data, error } = await sb.storage.from(ASSETS_BUCKET).download(path);
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
const buf = Buffer.from(await data.arrayBuffer());
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const type =
|
||||
ext === 'png'
|
||||
? 'image/png'
|
||||
: ext === 'webp'
|
||||
? 'image/webp'
|
||||
: ext === 'svg'
|
||||
? 'image/svg+xml'
|
||||
: 'image/jpeg';
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
127
src/app/api/calendar/collab/route.ts
Normal file
127
src/app/api/calendar/collab/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
dualStatus,
|
||||
insertComment,
|
||||
listApprovals,
|
||||
listComments,
|
||||
partyFromUser,
|
||||
upsertApproval,
|
||||
} from '@/lib/content-collab';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function gate() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Selecciona un cliente para colaborar en el calendario.' },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
return { user, ctx, party: partyFromUser(user) };
|
||||
}
|
||||
|
||||
/** GET ?ids=a,b,c — approvals map; &resourceId=x — also comments */
|
||||
export async function GET(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string };
|
||||
};
|
||||
|
||||
const ids = (req.nextUrl.searchParams.get('ids') || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const resourceId = req.nextUrl.searchParams.get('resourceId');
|
||||
|
||||
const approvals = await listApprovals(ctx.tenantId!, 'calendar_post', ids);
|
||||
const dual: Record<string, ReturnType<typeof dualStatus>> = {};
|
||||
for (const id of ids) {
|
||||
dual[id] = dualStatus(approvals[id] || []);
|
||||
}
|
||||
|
||||
let comments = null;
|
||||
if (resourceId) {
|
||||
comments = await listComments(ctx.tenantId!, 'calendar_post', resourceId);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
approvals,
|
||||
dual,
|
||||
comments,
|
||||
party: (g as { party: string }).party,
|
||||
isAgency: isAgencyRole(userRole((g as { user: unknown }).user as never)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST actions:
|
||||
* { action: 'approve' | 'revoke', postId, note? }
|
||||
* { action: 'comment', postId, body }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { user, ctx, party } = g as {
|
||||
user: { id: string; email?: string };
|
||||
ctx: { tenantId: string };
|
||||
party: 'agency' | 'client';
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const postId = String(body.postId || '');
|
||||
if (!postId) {
|
||||
return NextResponse.json({ error: 'postId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'approve' || body.action === 'revoke') {
|
||||
await upsertApproval({
|
||||
tenantId: ctx.tenantId,
|
||||
resourceType: 'calendar_post',
|
||||
resourceId: postId,
|
||||
party,
|
||||
approved: body.action === 'approve',
|
||||
actorId: user.id,
|
||||
actorEmail: user.email || null,
|
||||
note: body.note ? String(body.note) : null,
|
||||
});
|
||||
const approvals = await listApprovals(ctx.tenantId, 'calendar_post', [postId]);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
dual: dualStatus(approvals[postId] || []),
|
||||
party,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.action === 'comment') {
|
||||
const text = String(body.body || '').trim();
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Comentario vacío' }, { status: 400 });
|
||||
}
|
||||
const row = await insertComment({
|
||||
tenantId: ctx.tenantId,
|
||||
resourceType: 'calendar_post',
|
||||
resourceId: postId,
|
||||
body: text.slice(0, 4000),
|
||||
authorId: user.id,
|
||||
authorEmail: user.email || null,
|
||||
authorParty: party,
|
||||
});
|
||||
return NextResponse.json({ ok: true, comment: row });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'action inválida' }, { status: 400 });
|
||||
}
|
||||
84
src/app/api/calendar/publish/route.ts
Normal file
84
src/app/api/calendar/publish/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import octPlan from '@/data/rrss-octubre-2026.json';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST — publish bundled RRSS month plan into documents for the active tenant.
|
||||
* Agency only. Idempotent upsert by title + type.
|
||||
*/
|
||||
export async function POST() {
|
||||
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: 'Solo agencia' }, { status: 403 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 500 });
|
||||
}
|
||||
|
||||
const title = `RRSS ${octPlan.label}`;
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
const existing = await restGet<Array<{ id: string }>>(
|
||||
`/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&title=eq.${encodeURIComponent(title)}&select=id&limit=1`
|
||||
);
|
||||
|
||||
const payload = {
|
||||
tenant_id: ctx.tenantId,
|
||||
type: 'calendar',
|
||||
title,
|
||||
content: octPlan,
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
if (existing.data?.[0]?.id) {
|
||||
res = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/documents?id=eq.${existing.data[0].id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify({ content: octPlan, title }),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res = await fetch(`${supabaseUrl()}/rest/v1/documents`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const row = await res.json();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tenant: ctx.label,
|
||||
month: octPlan.month,
|
||||
pieces: (octPlan.pieces || []).length,
|
||||
document: Array.isArray(row) ? row[0] : row,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
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, KpiWeeklyRow } from '@/lib/metrics';
|
||||
import {
|
||||
SimChannel,
|
||||
simulateFromTenantMetrics,
|
||||
} from '@/lib/simulate';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||
@@ -12,10 +19,12 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const tenantId = ctx.tenantId ||
|
||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||
const companyName = user.user_metadata?.company || 'Cliente';
|
||||
if (!tenantId) {
|
||||
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
|
||||
if (!tenantId && !ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
|
||||
{ status: 403 }
|
||||
@@ -121,52 +130,85 @@ export async function POST(req: NextRequest) {
|
||||
normQuery.includes('presupuesto')
|
||||
) {
|
||||
try {
|
||||
const matchNumber = query.match(/(\d+[\d\.,]*)/);
|
||||
if (ctx.portfolioWide || !tenantId) {
|
||||
return reply(
|
||||
'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).'
|
||||
);
|
||||
}
|
||||
|
||||
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
||||
let amount = 1000;
|
||||
if (matchNumber) {
|
||||
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
||||
}
|
||||
|
||||
let channel: string | null = null;
|
||||
let channel: SimChannel = 'blended';
|
||||
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
|
||||
else if (normQuery.includes('google')) channel = 'google';
|
||||
else if (normQuery.includes('tiktok')) channel = 'tiktok';
|
||||
else if (normQuery.includes('linkedin')) channel = 'linkedin';
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
p_tenant_id: tenantId,
|
||||
p_additional_spend: amount,
|
||||
p_target_channel: channel
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const sim = await res.json();
|
||||
const p10 = sim.conservative_p10 || {};
|
||||
const p50 = sim.expected_p50 || {};
|
||||
const p90 = sim.optimistic_p90 || {};
|
||||
const base = sim.baseline || {};
|
||||
|
||||
const channelLabel = (sim.target_channel || 'General').toUpperCase();
|
||||
const text =
|
||||
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
|
||||
`📈 **Métricas Base Observadas:**\n` +
|
||||
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
|
||||
`- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` +
|
||||
`📊 **Proyección por Escenarios Matemáticos:**\n` +
|
||||
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
|
||||
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
|
||||
`- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` +
|
||||
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
|
||||
|
||||
return reply(text);
|
||||
else if (normQuery.includes('tiktok')) {
|
||||
return reply(
|
||||
'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.'
|
||||
);
|
||||
}
|
||||
|
||||
let daily: AdDailyRow[] = [];
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [d, w] = await Promise.all([
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
|
||||
),
|
||||
]);
|
||||
daily = d.data || [];
|
||||
weekly = w.data || [];
|
||||
} else {
|
||||
const [{ data: d }, { data: w }] = await Promise.all([
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
.limit(400),
|
||||
supabase
|
||||
.from('kpi_weekly')
|
||||
.select('*')
|
||||
.order('week', { ascending: false })
|
||||
.limit(52),
|
||||
]);
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
}
|
||||
|
||||
const sim = simulateFromTenantMetrics({
|
||||
spend: Number.isFinite(amount) && amount > 0 ? amount : 1000,
|
||||
channel,
|
||||
daily,
|
||||
weekly,
|
||||
});
|
||||
const p10 = sim.conservative_p10;
|
||||
const p50 = sim.expected_p50;
|
||||
const p90 = sim.optimistic_p90;
|
||||
const base = sim.baseline;
|
||||
const channelLabel = (sim.target_channel || 'blended').toUpperCase();
|
||||
const text =
|
||||
`🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` +
|
||||
`📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` +
|
||||
`- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||
`- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
|
||||
`- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
|
||||
`- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` +
|
||||
`📊 **Proyección:**\n` +
|
||||
`- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` +
|
||||
`- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` +
|
||||
`- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` +
|
||||
`💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`;
|
||||
|
||||
return reply(text);
|
||||
} catch (e) {
|
||||
console.error('Error in simulate_budget chat:', e);
|
||||
console.error('Error in tenant simulate chat:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +310,7 @@ export async function POST(req: NextRequest) {
|
||||
`${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`);
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`);
|
||||
} else {
|
||||
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.');
|
||||
}
|
||||
@@ -305,7 +347,7 @@ export async function POST(req: NextRequest) {
|
||||
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
|
||||
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
87
src/app/api/creatives/media/route.ts
Normal file
87
src/app/api/creatives/media/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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 { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET ?id= — redirect to signed URL for creative media */
|
||||
export async function GET(req: NextRequest) {
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
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: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
let storagePath: string | null = null;
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=storage_path,media_url`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 502 });
|
||||
}
|
||||
const rows = await r.json();
|
||||
storagePath = rows?.[0]?.storage_path || null;
|
||||
const mediaUrl = rows?.[0]?.media_url as string | undefined;
|
||||
if (!storagePath && mediaUrl && !mediaUrl.startsWith('/api/creatives/media')) {
|
||||
return NextResponse.redirect(mediaUrl);
|
||||
}
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('creatives')
|
||||
.select('storage_path,media_url')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
storagePath = data?.storage_path || null;
|
||||
if (
|
||||
!storagePath &&
|
||||
data?.media_url &&
|
||||
!String(data.media_url).startsWith('/api/creatives/media')
|
||||
) {
|
||||
return NextResponse.redirect(data.media_url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!storagePath) {
|
||||
return NextResponse.json({ error: 'Sin media' }, { status: 404 });
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const signed = await svc.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.createSignedUrl(storagePath, 300);
|
||||
if (signed.error || !signed.data?.signedUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: signed.error?.message || 'No signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(signed.data.signedUrl);
|
||||
}
|
||||
@@ -1,5 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
const SELECT =
|
||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'stories',
|
||||
'other',
|
||||
]);
|
||||
const FORMATS = new Set([
|
||||
'image',
|
||||
'video',
|
||||
'carousel',
|
||||
'reel',
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'pmax_landscape',
|
||||
'pmax_square',
|
||||
'pmax_portrait',
|
||||
'pmax_vertical',
|
||||
'pmax_logo',
|
||||
'youtube',
|
||||
'other',
|
||||
]);
|
||||
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
||||
|
||||
async function resolveTenantId(user: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}): Promise<{ tenantId: string | null; viaView: boolean }> {
|
||||
const role = userRole(user);
|
||||
const cookieStore = await cookies();
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
if (isAgencyRole(role) && viewId) {
|
||||
return { tenantId: viewId, viaView: true };
|
||||
}
|
||||
return { tenantId: homeTenantId(user), viaView: false };
|
||||
}
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
if (!t) return null;
|
||||
return t.slice(0, max);
|
||||
}
|
||||
|
||||
function previewFields(body: Record<string, unknown>) {
|
||||
const channel = str(body.channel, 40);
|
||||
const format = str(body.format, 40);
|
||||
return {
|
||||
channel: channel && CHANNELS.has(channel) ? channel : null,
|
||||
placement: str(body.placement, 40),
|
||||
format: format && FORMATS.has(format) ? format : null,
|
||||
headline: str(body.headline, 500),
|
||||
primary_text: str(body.primary_text, 8000),
|
||||
description: str(body.description, 2000),
|
||||
cta: str(body.cta, 120),
|
||||
media_url: str(body.media_url, 2000),
|
||||
brand_name: str(body.brand_name, 200),
|
||||
landing_url: str(body.landing_url, 2000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
@@ -10,17 +87,67 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { tenantId, viaView } = await resolveTenantId(user);
|
||||
|
||||
if (viaView && tenantId) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: 'Service role required for superadmin view' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
// Fallback if preview columns not migrated yet
|
||||
const fallback = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (fallback.ok) {
|
||||
return NextResponse.json({
|
||||
creatives: await fallback.json(),
|
||||
viewTenantId: tenantId,
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: [],
|
||||
warning: await r.text(),
|
||||
viewTenantId: tenantId,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: await r.json(),
|
||||
viewTenantId: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.select('id,title,status,mime_type,bytes,created_at,updated_at')
|
||||
.select(SELECT)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
// Table may not exist yet — soft message for pre-migration
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: error.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
const { data: basic, error: e2 } = await supabase
|
||||
.from('creatives')
|
||||
.select('id,title,status,mime_type,bytes,created_at,updated_at')
|
||||
.order('updated_at', { ascending: false });
|
||||
if (e2) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: error.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: basic ?? [],
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ creatives: data ?? [] });
|
||||
@@ -35,32 +162,179 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||
const { tenantId, viaView } = await resolveTenantId(user);
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const title = str(body.title, 300);
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
tenant_id: tenantId,
|
||||
title,
|
||||
status: 'draft',
|
||||
created_by: user.id,
|
||||
...previewFields(body),
|
||||
};
|
||||
|
||||
if (viaView) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Service role required' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
|
||||
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 data = await r.json();
|
||||
return NextResponse.json(
|
||||
{ creative: Array.isArray(data) ? data[0] : data },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
title,
|
||||
status: 'draft',
|
||||
created_by: user.id,
|
||||
})
|
||||
.select('id,title,status,created_at')
|
||||
.insert(row)
|
||||
.select(SELECT)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
// Retry without preview columns if migration pending
|
||||
const { data: basic, error: e2 } = await supabase
|
||||
.from('creatives')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
title,
|
||||
status: 'draft',
|
||||
created_by: user.id,
|
||||
})
|
||||
.select('id,title,status,created_at')
|
||||
.single();
|
||||
if (e2) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
creative: basic,
|
||||
warning: 'Preview columns missing — apply sql/creatives_preview.sql',
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ creative: data }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const id = str(body.id, 80);
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const clean: Record<string, unknown> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (typeof body.status === 'string' && ALLOWED_STATUS.has(body.status)) {
|
||||
clean.status = body.status;
|
||||
}
|
||||
const title = str(body.title, 300);
|
||||
if (title) clean.title = title;
|
||||
|
||||
for (const [k, v] of Object.entries(previewFields(body))) {
|
||||
if (k in body) clean[k] = v;
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(clean),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({ creative: data[0] || data });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(clean)
|
||||
.eq('id', id)
|
||||
.select(SELECT)
|
||||
.single();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('creatives').delete().eq('id', id);
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
197
src/app/api/creatives/upload/route.ts
Normal file
197
src/app/api/creatives/upload/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 { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
MAX_ASSET_BYTES,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST multipart: upload creative media and attach to a creative row.
|
||||
* fields: file, creativeId? (optional — creates draft if missing), title?
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED_MIME.has(mime) && !mime.startsWith('image/') && !mime.startsWith('video/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo imagen o vídeo (jpg/png/webp/gif/mp4/webm)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
let creativeId =
|
||||
typeof form.get('creativeId') === 'string'
|
||||
? String(form.get('creativeId')).trim()
|
||||
: '';
|
||||
const title =
|
||||
typeof form.get('title') === 'string'
|
||||
? String(form.get('title')).trim()
|
||||
: file.name.replace(/\.[^.]+$/, '') || 'Creativo subido';
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
|
||||
if (!creativeId) {
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title: title.slice(0, 300),
|
||||
status: 'draft',
|
||||
channel: 'meta_ads',
|
||||
format: mime.startsWith('video/') ? 'video' : 'image',
|
||||
created_by: user.id,
|
||||
};
|
||||
if (agency && headers) {
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
|
||||
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 data = await r.json();
|
||||
creativeId = Array.isArray(data) ? data[0]?.id : data?.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'No se pudo crear' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
creativeId = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!creativeId) {
|
||||
return NextResponse.json({ error: 'creativeId inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const safeName = sanitizeFileName(file.name || 'creative.bin');
|
||||
const storagePath = `${ctx.tenantId}/creatives/${creativeId}/${randomUUID()}-${safeName}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||
contentType: mime,
|
||||
upsert: false,
|
||||
});
|
||||
if (up.error) {
|
||||
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
|
||||
const patch = {
|
||||
storage_path: storagePath,
|
||||
mime_type: mime,
|
||||
bytes: file.size,
|
||||
media_url: mediaUrl,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({
|
||||
creative: Array.isArray(data) ? data[0] : data,
|
||||
media_url: mediaUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||
}
|
||||
43
src/app/api/cron/ads-sync/route.ts
Normal file
43
src/app/api/cron/ads-sync/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily Meta + Google Ads → ad_daily.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const days = Math.min(
|
||||
90,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
);
|
||||
|
||||
try {
|
||||
const { results, days: d } = await syncAdsForTenants({ days });
|
||||
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
|
||||
const withErrors = results.filter((r) => r.errors.length).length;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
days: d,
|
||||
synced: ok,
|
||||
withErrors,
|
||||
results,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
100
src/app/api/cron/daily-sync/route.ts
Normal file
100
src/app/api/cron/daily-sync/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Master daily job: organic + ads + superhuman.
|
||||
* Dokploy (daily ~06:00 UTC):
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* "https://app.4ventures.es/api/cron/daily-sync"
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const days = Math.min(
|
||||
28,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
);
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
ok: true,
|
||||
days,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 1) Organic
|
||||
try {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (!oauth.connected) {
|
||||
out.organic = { ok: false, warning: 'Google agencia no conectado' };
|
||||
} else {
|
||||
out.organic = {
|
||||
ok: true,
|
||||
...(await syncAllConnectedOrganic({ days })),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
out.organic = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'organic failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Ads
|
||||
try {
|
||||
const ads = await syncAdsForTenants({ 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,
|
||||
};
|
||||
} catch (e) {
|
||||
out.ads = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'ads failed',
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Superhuman
|
||||
try {
|
||||
if (!superhumanConfigured()) {
|
||||
out.superhuman = {
|
||||
ok: false,
|
||||
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||
};
|
||||
} else {
|
||||
out.superhuman = {
|
||||
ok: true,
|
||||
...(await syncAllSuperhumanTenants({
|
||||
maxEntries: 80,
|
||||
exportBodies: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
out.superhuman = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'superhuman failed',
|
||||
};
|
||||
}
|
||||
|
||||
const failed = ['organic', 'ads', 'superhuman'].some(
|
||||
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
|
||||
);
|
||||
return NextResponse.json(out, { status: failed ? 207 : 200 });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
48
src/app/api/cron/organic-sync/route.ts
Normal file
48
src/app/api/cron/organic-sync/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily organic sync for all connected tenants.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const status = await getAgencyOAuthStatus();
|
||||
if (!status.connected) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
warning: 'Google agencia no conectado',
|
||||
});
|
||||
}
|
||||
|
||||
const days = Math.min(
|
||||
28,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await syncAllConnectedOrganic({ days });
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
days,
|
||||
...result,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import {
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily Superhuman / Seguimiento sync for all tenants.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
if (!superhumanConfigured()) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncAllSuperhumanTenants({
|
||||
maxEntries: 100,
|
||||
exportBodies: true,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
...result,
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
652
src/app/api/organico/route.ts
Normal file
652
src/app/api/organico/route.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
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 type { GeoSnapshot, OrganicDailyRow, SeoAuditRow } from '@/lib/organic';
|
||||
import {
|
||||
fetchGa4TopLandings,
|
||||
fetchGscTopRows,
|
||||
organicNeedsRefresh,
|
||||
syncAllConnectedOrganic,
|
||||
} from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
import { isoYesterday } from '@/lib/ad-tracker-range';
|
||||
import { runSeoSiteAudit } from '@/lib/seo-audit';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
async function loadLiveSeoDims(
|
||||
integrations: { provider?: string; status?: string; external_account_id?: string | null; meta?: { property_id?: string; site_url?: string } }[]
|
||||
): Promise<{
|
||||
gscQueries: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||
gscPages: { key: string; clicks: number; impressions: number; ctr: number; position: number }[];
|
||||
ga4Landings: { page: string; sessions: number; users: number }[];
|
||||
liveWarning?: string;
|
||||
}> {
|
||||
const empty = { gscQueries: [], gscPages: [], ga4Landings: [] };
|
||||
try {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (!oauth.connected) return empty;
|
||||
|
||||
const gsc = integrations.find(
|
||||
(i) => i.provider === 'search_console' && i.status === 'connected'
|
||||
);
|
||||
const ga4 = integrations.find(
|
||||
(i) => i.provider === 'ga4' && i.status === 'connected'
|
||||
);
|
||||
const until = isoYesterday();
|
||||
const sinceDate = new Date(`${until}T12:00:00Z`);
|
||||
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
|
||||
const since = sinceDate.toISOString().slice(0, 10);
|
||||
|
||||
const siteUrl =
|
||||
gsc?.meta?.site_url ||
|
||||
(gsc?.external_account_id?.includes('http') ||
|
||||
gsc?.external_account_id?.startsWith('sc-domain:')
|
||||
? gsc.external_account_id
|
||||
: null);
|
||||
const propertyId =
|
||||
ga4?.meta?.property_id ||
|
||||
(ga4?.external_account_id?.startsWith('properties/')
|
||||
? ga4.external_account_id
|
||||
: ga4?.external_account_id?.match(/^\d+$/)
|
||||
? `properties/${ga4.external_account_id}`
|
||||
: null);
|
||||
|
||||
const warnings: string[] = [];
|
||||
let gscQueries: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
|
||||
let gscPages: Awaited<ReturnType<typeof fetchGscTopRows>> = [];
|
||||
let ga4Landings: Awaited<ReturnType<typeof fetchGa4TopLandings>> = [];
|
||||
|
||||
if (siteUrl) {
|
||||
try {
|
||||
gscQueries = await fetchGscTopRows({
|
||||
siteUrl,
|
||||
startDate: since,
|
||||
endDate: until,
|
||||
dimension: 'query',
|
||||
});
|
||||
} catch (e) {
|
||||
warnings.push(e instanceof Error ? e.message : 'gsc queries');
|
||||
}
|
||||
try {
|
||||
gscPages = await fetchGscTopRows({
|
||||
siteUrl,
|
||||
startDate: since,
|
||||
endDate: until,
|
||||
dimension: 'page',
|
||||
});
|
||||
} catch (e) {
|
||||
warnings.push(e instanceof Error ? e.message : 'gsc pages');
|
||||
}
|
||||
}
|
||||
if (propertyId) {
|
||||
try {
|
||||
ga4Landings = await fetchGa4TopLandings({
|
||||
propertyId,
|
||||
startDate: since,
|
||||
endDate: until,
|
||||
});
|
||||
} catch (e) {
|
||||
warnings.push(e instanceof Error ? e.message : 'ga4 landings');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gscQueries,
|
||||
gscPages,
|
||||
ga4Landings,
|
||||
liveWarning: warnings[0],
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function gate() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
return { user, ctx, agency, headers: serviceHeaders() };
|
||||
}
|
||||
|
||||
/** GET — organic series + latest GEO snapshot + integration status for active tenant. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string | null; portfolioWide: boolean; label: string };
|
||||
agency: boolean;
|
||||
headers: ReturnType<typeof serviceHeaders>;
|
||||
};
|
||||
|
||||
if (ctx.portfolioWide || !ctx.tenantId) {
|
||||
return NextResponse.json({
|
||||
portfolioWide: true,
|
||||
organic: [] as OrganicDailyRow[],
|
||||
geo: [] as GeoSnapshot[],
|
||||
seoAudits: [] as SeoAuditRow[],
|
||||
integrations: [],
|
||||
warning: 'Selecciona un cliente en Contexto para ver Orgánico.',
|
||||
});
|
||||
}
|
||||
|
||||
const tid = ctx.tenantId;
|
||||
const since = req.nextUrl.searchParams.get('since') || '';
|
||||
const until = req.nextUrl.searchParams.get('until') || '';
|
||||
const skipAuto = req.nextUrl.searchParams.get('skipAutoSync') === '1';
|
||||
|
||||
// Keep Orgánico fresh when opening the page (if Google OAuth is connected)
|
||||
let autoSynced: { daysWritten: number; warning?: string } | null = null;
|
||||
if (!skipAuto) {
|
||||
try {
|
||||
const oauth = await getAgencyOAuthStatus();
|
||||
if (oauth.connected && (await organicNeedsRefresh(tid))) {
|
||||
const r = await syncAllConnectedOrganic({ tenantId: tid, days: 28 });
|
||||
const one = r.results.find((x) => x.tenantId === tid);
|
||||
autoSynced = {
|
||||
daysWritten: one?.daysWritten || 0,
|
||||
warning: one?.warning,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// non-fatal — still return cached organic rows
|
||||
}
|
||||
}
|
||||
|
||||
if (agency && headers) {
|
||||
const base = supabaseUrl();
|
||||
const dayFilter =
|
||||
since && until
|
||||
? `&day=gte.${since}&day=lte.${until}`
|
||||
: '';
|
||||
const [oRes, gRes, iRes, tRes, aRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=day.asc${dayFilter}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/geo_snapshots?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=12`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=in.(ga4,search_console,tag_manager)&select=provider,status,enabled,external_account_id,last_sync_at,error_message,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tid)}&select=settings`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/seo_audits?tenant_id=eq.${encodeURIComponent(tid)}&select=*&order=created_at.desc&limit=20`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
|
||||
const warningParts: string[] = [];
|
||||
let organic: OrganicDailyRow[] = [];
|
||||
let geo: GeoSnapshot[] = [];
|
||||
let integrations: unknown[] = [];
|
||||
let workspace: Record<string, unknown> = {};
|
||||
let seoAudits: SeoAuditRow[] = [];
|
||||
|
||||
if (oRes.ok) organic = await oRes.json();
|
||||
else {
|
||||
const t = await oRes.text();
|
||||
warningParts.push(
|
||||
t.includes('organic_daily')
|
||||
? 'Aplicar sql/organic_geo.sql'
|
||||
: t.slice(0, 120)
|
||||
);
|
||||
}
|
||||
if (gRes.ok) geo = await gRes.json();
|
||||
else {
|
||||
const t = await gRes.text();
|
||||
if (t.includes('geo_snapshots')) {
|
||||
warningParts.push('Aplicar sql/organic_geo.sql (GEO)');
|
||||
}
|
||||
}
|
||||
if (iRes.ok) integrations = await iRes.json();
|
||||
if (tRes.ok) {
|
||||
const rows = await tRes.json();
|
||||
workspace = rows[0]?.settings?.organico || {};
|
||||
}
|
||||
if (aRes.ok) {
|
||||
seoAudits = await aRes.json();
|
||||
} else {
|
||||
const t = await aRes.text();
|
||||
if (t.includes('seo_audits')) {
|
||||
const fallback = Array.isArray(workspace.seoAudits)
|
||||
? (workspace.seoAudits as SeoAuditRow[])
|
||||
: [];
|
||||
seoAudits = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const live = await loadLiveSeoDims(
|
||||
integrations as {
|
||||
provider?: string;
|
||||
status?: string;
|
||||
external_account_id?: string | null;
|
||||
meta?: { property_id?: string; site_url?: string };
|
||||
}[]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
portfolioWide: false,
|
||||
label: ctx.label,
|
||||
tenantId: tid,
|
||||
organic,
|
||||
geo,
|
||||
seoAudits,
|
||||
integrations,
|
||||
workspace,
|
||||
autoSynced,
|
||||
gscQueries: live.gscQueries,
|
||||
gscPages: live.gscPages,
|
||||
ga4Landings: live.ga4Landings,
|
||||
warning:
|
||||
warningParts[0] ||
|
||||
autoSynced?.warning ||
|
||||
live.liveWarning ||
|
||||
null,
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
let organicQ = supabase
|
||||
.from('organic_daily')
|
||||
.select('*')
|
||||
.order('day', { ascending: true });
|
||||
if (since) organicQ = organicQ.gte('day', since);
|
||||
if (until) organicQ = organicQ.lte('day', until);
|
||||
const [o, ge, integ, ten, aud] = await Promise.all([
|
||||
organicQ,
|
||||
supabase
|
||||
.from('geo_snapshots')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(12),
|
||||
supabase
|
||||
.from('integrations')
|
||||
.select(
|
||||
'provider,status,enabled,external_account_id,last_sync_at,error_message,meta'
|
||||
)
|
||||
.in('provider', ['ga4', 'search_console', 'tag_manager']),
|
||||
supabase.from('tenants').select('settings').eq('id', tid).maybeSingle(),
|
||||
supabase
|
||||
.from('seo_audits')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
]);
|
||||
|
||||
const workspace =
|
||||
(ten.data?.settings as { organico?: Record<string, unknown> } | null)
|
||||
?.organico || {};
|
||||
let seoAudits: SeoAuditRow[] = (aud.data as SeoAuditRow[]) || [];
|
||||
if (aud.error && Array.isArray(workspace.seoAudits)) {
|
||||
seoAudits = workspace.seoAudits as SeoAuditRow[];
|
||||
}
|
||||
|
||||
const live = await loadLiveSeoDims(
|
||||
(integ.data || []) as {
|
||||
provider?: string;
|
||||
status?: string;
|
||||
external_account_id?: string | null;
|
||||
meta?: { property_id?: string; site_url?: string };
|
||||
}[]
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
portfolioWide: false,
|
||||
label: ctx.label,
|
||||
tenantId: tid,
|
||||
organic: o.data || [],
|
||||
geo: ge.data || [],
|
||||
seoAudits,
|
||||
integrations: integ.data || [],
|
||||
workspace,
|
||||
autoSynced,
|
||||
gscQueries: live.gscQueries,
|
||||
gscPages: live.gscPages,
|
||||
ga4Landings: live.ga4Landings,
|
||||
warning:
|
||||
o.error?.message ||
|
||||
ge.error?.message ||
|
||||
autoSynced?.warning ||
|
||||
live.liveWarning ||
|
||||
null,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST — agency: upsert organic day OR create GEO snapshot. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await gate();
|
||||
if ('error' in g && g.error) return g.error;
|
||||
const { ctx, agency, headers } = g as Awaited<ReturnType<typeof gate>> & {
|
||||
ctx: { tenantId: string | null };
|
||||
agency: boolean;
|
||||
headers: ReturnType<typeof serviceHeaders>;
|
||||
};
|
||||
if (!agency) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
if (!ctx.tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const kind =
|
||||
body.kind === 'geo'
|
||||
? 'geo'
|
||||
: body.kind === 'workspace'
|
||||
? 'workspace'
|
||||
: body.kind === 'seo-audit'
|
||||
? 'seo-audit'
|
||||
: 'organic';
|
||||
const base = supabaseUrl();
|
||||
|
||||
if (kind === 'seo-audit') {
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&provider=in.(ga4,search_console)&select=provider,status,external_account_id,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const integrations = iRes.ok
|
||||
? ((await iRes.json()) as {
|
||||
provider?: string;
|
||||
status?: string;
|
||||
external_account_id?: string | null;
|
||||
meta?: { site_url?: string; property_id?: string };
|
||||
}[])
|
||||
: [];
|
||||
const gsc = integrations.find(
|
||||
(i) => i.provider === 'search_console' && i.status === 'connected'
|
||||
);
|
||||
const ga4 = integrations.find(
|
||||
(i) => i.provider === 'ga4' && i.status === 'connected'
|
||||
);
|
||||
const gscSiteUrl =
|
||||
gsc?.meta?.site_url ||
|
||||
(gsc?.external_account_id?.includes('http') ||
|
||||
gsc?.external_account_id?.startsWith('sc-domain:')
|
||||
? gsc.external_account_id
|
||||
: null);
|
||||
|
||||
let samplePages: string[] = [];
|
||||
if (gscSiteUrl) {
|
||||
try {
|
||||
const until = isoYesterday();
|
||||
const sinceDate = new Date(`${until}T12:00:00Z`);
|
||||
sinceDate.setUTCDate(sinceDate.getUTCDate() - 27);
|
||||
const since = sinceDate.toISOString().slice(0, 10);
|
||||
const pages = await fetchGscTopRows({
|
||||
siteUrl: gscSiteUrl,
|
||||
startDate: since,
|
||||
endDate: until,
|
||||
dimension: 'page',
|
||||
});
|
||||
samplePages = pages.map((p) => p.key).slice(0, 3);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const result = await runSeoSiteAudit({
|
||||
gscSiteUrl,
|
||||
ga4Connected: ga4?.status === 'connected',
|
||||
gscConnected: gsc?.status === 'connected',
|
||||
samplePages,
|
||||
});
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
site_url: result.siteUrl,
|
||||
score: result.score,
|
||||
grade: result.grade,
|
||||
warning_count: result.warningCount,
|
||||
critical_count: result.criticalCount,
|
||||
checks: result.checks,
|
||||
summary: result.summary,
|
||||
};
|
||||
|
||||
const r = await fetch(`${base}/rest/v1/seo_audits`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
const saved = (await r.json())[0] as SeoAuditRow;
|
||||
return NextResponse.json({ audit: saved });
|
||||
}
|
||||
|
||||
// Fallback: persist last 20 audits in tenants.settings.organico.seoAudits
|
||||
const msg = await r.text();
|
||||
if (!msg.includes('seo_audits')) {
|
||||
return NextResponse.json({ error: msg.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
|
||||
const cur = await fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (cur.ok) {
|
||||
const rows = await cur.json();
|
||||
settings =
|
||||
rows[0]?.settings && typeof rows[0].settings === 'object'
|
||||
? { ...rows[0].settings }
|
||||
: {};
|
||||
}
|
||||
const prevOrg =
|
||||
settings.organico && typeof settings.organico === 'object'
|
||||
? (settings.organico as Record<string, unknown>)
|
||||
: {};
|
||||
const prevAudits = Array.isArray(prevOrg.seoAudits)
|
||||
? (prevOrg.seoAudits as SeoAuditRow[])
|
||||
: [];
|
||||
const audit: SeoAuditRow = {
|
||||
id: `ws-${Date.now()}`,
|
||||
...row,
|
||||
created_at: result.createdAt,
|
||||
};
|
||||
settings.organico = {
|
||||
...prevOrg,
|
||||
seoAudits: [audit, ...prevAudits].slice(0, 20),
|
||||
};
|
||||
const patch = await fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
}
|
||||
);
|
||||
if (!patch.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (await patch.text()).slice(0, 300) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
audit,
|
||||
warning: 'seo_audits table missing — histórico en settings (aplicar sql/seo_audits.sql)',
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'workspace') {
|
||||
// Merge organico workspace into tenants.settings
|
||||
const cur = await fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=settings`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (cur.ok) {
|
||||
const rows = await cur.json();
|
||||
settings =
|
||||
rows[0]?.settings && typeof rows[0].settings === 'object'
|
||||
? { ...rows[0].settings }
|
||||
: {};
|
||||
}
|
||||
const prev =
|
||||
settings.organico && typeof settings.organico === 'object'
|
||||
? (settings.organico as Record<string, unknown>)
|
||||
: {};
|
||||
settings.organico = {
|
||||
...prev,
|
||||
...(body.workspace && typeof body.workspace === 'object'
|
||||
? body.workspace
|
||||
: {}),
|
||||
};
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify({ settings }),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (await r.text()).slice(0, 300) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
workspace: (await r.json())[0]?.settings?.organico || settings.organico,
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'organic') {
|
||||
const day = typeof body.day === 'string' ? body.day.slice(0, 10) : '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||||
return NextResponse.json({ error: 'day YYYY-MM-DD requerido' }, { status: 400 });
|
||||
}
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
day,
|
||||
gsc_clicks: Number(body.gsc_clicks) || 0,
|
||||
gsc_impressions: Number(body.gsc_impressions) || 0,
|
||||
gsc_ctr:
|
||||
body.gsc_ctr != null
|
||||
? Number(body.gsc_ctr)
|
||||
: Number(body.gsc_impressions) > 0
|
||||
? (100 * Number(body.gsc_clicks || 0)) /
|
||||
Number(body.gsc_impressions)
|
||||
: null,
|
||||
gsc_avg_position:
|
||||
body.gsc_avg_position != null ? Number(body.gsc_avg_position) : null,
|
||||
ga4_sessions: Number(body.ga4_sessions) || 0,
|
||||
ga4_users: Number(body.ga4_users) || 0,
|
||||
ga4_organic_sessions: Number(body.ga4_organic_sessions) || 0,
|
||||
ga4_conversions: Number(body.ga4_conversions) || 0,
|
||||
source: 'manual',
|
||||
};
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
const msg = await r.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('organic_daily')
|
||||
? 'Aplicar sql/organic_geo.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ row: (await r.json())[0] });
|
||||
}
|
||||
|
||||
// GEO snapshot
|
||||
const snapshot = {
|
||||
tenant_id: ctx.tenantId,
|
||||
label:
|
||||
typeof body.label === 'string' && body.label.trim()
|
||||
? body.label.trim()
|
||||
: 'Medición GEO',
|
||||
period_start: body.period_start || null,
|
||||
period_end: body.period_end || null,
|
||||
share_of_model:
|
||||
body.share_of_model != null ? Number(body.share_of_model) : null,
|
||||
engines: body.engines && typeof body.engines === 'object' ? body.engines : {},
|
||||
official_citation_pct:
|
||||
body.official_citation_pct != null
|
||||
? Number(body.official_citation_pct)
|
||||
: null,
|
||||
sentiment_score:
|
||||
body.sentiment_score != null ? Number(body.sentiment_score) : null,
|
||||
competitors: Array.isArray(body.competitors) ? body.competitors : [],
|
||||
categories: Array.isArray(body.categories) ? body.categories : [],
|
||||
top_sources: Array.isArray(body.top_sources) ? body.top_sources : [],
|
||||
prompts_analyzed:
|
||||
body.prompts_analyzed != null ? Number(body.prompts_analyzed) : null,
|
||||
responses_analyzed:
|
||||
body.responses_analyzed != null ? Number(body.responses_analyzed) : null,
|
||||
study_url: typeof body.study_url === 'string' ? body.study_url : null,
|
||||
notes: typeof body.notes === 'string' ? body.notes : null,
|
||||
};
|
||||
|
||||
const r = await fetch(`${base}/rest/v1/geo_snapshots`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(snapshot),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const msg = await r.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('geo_snapshots')
|
||||
? 'Aplicar sql/organic_geo.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ snapshot: (await r.json())[0] });
|
||||
}
|
||||
176
src/app/api/seguimiento/route.ts
Normal file
176
src/app/api/seguimiento/route.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export type SeguimientoEntry = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
entry_type: string;
|
||||
title: string;
|
||||
summary?: string | null;
|
||||
body_md?: string | null;
|
||||
browser_link?: string | null;
|
||||
metrics?: Record<string, unknown>;
|
||||
occurred_at?: string | null;
|
||||
synced_at?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
/** GET — notas + histórico Superhuman (sin series ads). */
|
||||
export async function GET() {
|
||||
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);
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
|
||||
if (agency && ctx.portfolioWide) {
|
||||
return NextResponse.json({
|
||||
label: ctx.label,
|
||||
portfolioWide: true,
|
||||
entries: [],
|
||||
hint: 'Elige un cliente en el selector para ver su seguimiento.',
|
||||
});
|
||||
}
|
||||
|
||||
const tenantId = ctx.tenantId;
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
let entries: SeguimientoEntry[] = [];
|
||||
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const e = await restGet<SeguimientoEntry[]>(
|
||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=300`
|
||||
);
|
||||
entries = e.data || [];
|
||||
} else {
|
||||
const { data: e } = await supabase
|
||||
.from('seguimiento_entries')
|
||||
.select('*')
|
||||
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||
.limit(300);
|
||||
entries = (e as SeguimientoEntry[]) || [];
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
label: ctx.label,
|
||||
portfolioWide: false,
|
||||
tenantId,
|
||||
entries,
|
||||
isAgency: agency,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — add a manual note / comentario to seguimiento.
|
||||
* { title?, body, occurredAt? | occurred_at? }
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const text =
|
||||
typeof body.body === 'string'
|
||||
? body.body.trim()
|
||||
: typeof body.note === 'string'
|
||||
? body.note.trim()
|
||||
: '';
|
||||
const plain = text
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!plain) {
|
||||
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
|
||||
}
|
||||
const title =
|
||||
typeof body.title === 'string' && body.title.trim()
|
||||
? body.title.trim().slice(0, 200)
|
||||
: `Nota · ${new Date().toLocaleDateString('es-ES')}`;
|
||||
const occurredRaw =
|
||||
(typeof body.occurredAt === 'string' && body.occurredAt) ||
|
||||
(typeof body.occurred_at === 'string' && body.occurred_at) ||
|
||||
new Date().toISOString();
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
source: 'manual',
|
||||
entry_type: 'note',
|
||||
external_id: `manual-${randomUUID()}`,
|
||||
title,
|
||||
summary: plain.slice(0, 280),
|
||||
body_md: text,
|
||||
occurred_at: occurredRaw,
|
||||
synced_at: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/seguimiento_entries`, {
|
||||
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 data = await r.json();
|
||||
return NextResponse.json(
|
||||
{ entry: Array.isArray(data) ? data[0] : data },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('seguimiento_entries')
|
||||
.insert(row)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ entry: data }, { status: 201 });
|
||||
}
|
||||
@@ -1,10 +1,52 @@
|
||||
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, KpiWeeklyRow } from '@/lib/metrics';
|
||||
import {
|
||||
SimChannel,
|
||||
simulateFromTenantMetrics,
|
||||
} from '@/lib/simulate';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function loadTenantMetrics(tenantId: string, agencyView: boolean) {
|
||||
let daily: AdDailyRow[] = [];
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
|
||||
if (agencyView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [d, w] = await Promise.all([
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
|
||||
),
|
||||
]);
|
||||
daily = d.data || [];
|
||||
weekly = w.data || [];
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
const [{ data: d }, { data: w }] = await Promise.all([
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
.limit(400),
|
||||
supabase
|
||||
.from('kpi_weekly')
|
||||
.select('*')
|
||||
.order('week', { ascending: false })
|
||||
.limit(52),
|
||||
]);
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
}
|
||||
return { daily, weekly };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
@@ -13,42 +55,50 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||
if (!tenantId) {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usuario sin tenant_id' },
|
||||
{ status: 403 }
|
||||
{ error: 'Selecciona un cliente en Contexto para simular.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const spend = parseFloat(body.spend || 1000);
|
||||
const channel = body.channel || 'meta';
|
||||
let channel = String(body.channel || 'blended').toLowerCase();
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: keyForRpc,
|
||||
Authorization: `Bearer ${keyForRpc}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_tenant_id: tenantId,
|
||||
p_additional_spend: spend,
|
||||
p_target_channel: channel === 'blended' ? null : channel,
|
||||
}),
|
||||
if (channel === 'tiktok') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!['meta', 'google', 'blended'].includes(channel)) {
|
||||
channel = 'blended';
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const { daily, weekly } = await loadTenantMetrics(
|
||||
ctx.tenantId,
|
||||
agency && ctx.viaView
|
||||
);
|
||||
|
||||
const sim = simulateFromTenantMetrics({
|
||||
spend: Number.isFinite(spend) && spend > 0 ? spend : 1000,
|
||||
channel: channel as SimChannel,
|
||||
daily,
|
||||
weekly,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
const err = await res.text();
|
||||
return NextResponse.json({ error: err }, { status: 500 });
|
||||
return NextResponse.json({
|
||||
...sim,
|
||||
tenant_label: ctx.label,
|
||||
hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).',
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
|
||||
930
src/app/api/social/route.ts
Normal file
930
src/app/api/social/route.ts
Normal file
@@ -0,0 +1,930 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 90;
|
||||
|
||||
const META = 'https://graph.facebook.com/v21.0';
|
||||
|
||||
type Integ = {
|
||||
provider: string;
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
label?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function cacheSocialSnapshot(
|
||||
tenantId: string,
|
||||
provider: 'facebook' | 'instagram',
|
||||
payload: unknown
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const now = new Date().toISOString();
|
||||
// Merge into meta without wiping other keys: read-modify-write
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let meta: Record<string, unknown> = {};
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
|
||||
meta = { ...(rows[0]?.meta || {}) };
|
||||
}
|
||||
// Keep a compact snapshot (metrics + up to 12 posts)
|
||||
const snap =
|
||||
payload && typeof payload === 'object'
|
||||
? {
|
||||
...(payload as object),
|
||||
posts: Array.isArray((payload as { posts?: unknown }).posts)
|
||||
? (payload as { posts: unknown[] }).posts.slice(0, 12)
|
||||
: undefined,
|
||||
}
|
||||
: payload;
|
||||
meta.social_cache = snap;
|
||||
meta.social_cached_at = now;
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meta,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readSocialCache(integrations: Integ[]): {
|
||||
facebook?: unknown;
|
||||
instagram?: unknown;
|
||||
at?: string;
|
||||
} {
|
||||
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
|
||||
for (const i of integrations) {
|
||||
const cache = i.meta?.social_cache;
|
||||
const at = typeof i.meta?.social_cached_at === 'string'
|
||||
? i.meta.social_cached_at
|
||||
: i.last_sync_at || undefined;
|
||||
if (!cache) continue;
|
||||
if (i.provider === 'facebook') out.facebook = cache;
|
||||
if (i.provider === 'instagram') out.instagram = cache;
|
||||
if (at && (!out.at || at > out.at)) out.at = at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SocialPost = {
|
||||
id: string;
|
||||
platform: 'facebook' | 'instagram';
|
||||
message?: string;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
permalink?: string | null;
|
||||
createdAt?: string | null;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaType?: string | null;
|
||||
/** FEED | REELS | STORY | … */
|
||||
mediaProductType?: string | null;
|
||||
/** Slides for CAROUSEL_ALBUM / album */
|
||||
children?: {
|
||||
id?: string;
|
||||
mediaType?: string | null;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DayPoint = { day: string; value: number };
|
||||
|
||||
function isVideoType(t?: string | null): boolean {
|
||||
const s = (t || '').toUpperCase();
|
||||
return (
|
||||
s === 'VIDEO' ||
|
||||
s === 'REELS' ||
|
||||
s === 'REEL' ||
|
||||
s.includes('VIDEO') ||
|
||||
s === 'ANIMATED_IMAGE_VIDEO'
|
||||
);
|
||||
}
|
||||
|
||||
function formatGraphError(raw: string): string {
|
||||
try {
|
||||
const j = JSON.parse(raw) as {
|
||||
error?: {
|
||||
message?: string;
|
||||
code?: number;
|
||||
error_subcode?: number;
|
||||
error_user_title?: string;
|
||||
error_user_msg?: string;
|
||||
};
|
||||
};
|
||||
const e = j.error;
|
||||
if (!e) return raw.slice(0, 220);
|
||||
if (e.code === 190 || e.error_subcode === 2069032) {
|
||||
return (
|
||||
'Token Meta inválido para esta Página. Usa el system user de la app 4V2030 ' +
|
||||
'con pages_* + instagram_* + ads_*, asígnalo a la Página y pégalo en Agencia ' +
|
||||
'(o META_ACCESS_TOKEN en Dokploy).'
|
||||
);
|
||||
}
|
||||
return (
|
||||
e.error_user_title ||
|
||||
e.error_user_msg ||
|
||||
e.message ||
|
||||
raw
|
||||
).slice(0, 280);
|
||||
} catch {
|
||||
return raw.slice(0, 220);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePageAccessToken(
|
||||
userToken: string,
|
||||
pageId: string
|
||||
): Promise<string> {
|
||||
const r = await fetch(
|
||||
`${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
const j = (await r.json()) as { access_token?: string };
|
||||
if (j.access_token) return j.access_token;
|
||||
}
|
||||
const accounts = await fetch(
|
||||
`${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (accounts.ok) {
|
||||
const j = (await accounts.json()) as {
|
||||
data?: { id?: string; access_token?: string }[];
|
||||
};
|
||||
const hit = (j.data || []).find((p) => p.id === pageId && p.access_token);
|
||||
if (hit?.access_token) return hit.access_token;
|
||||
}
|
||||
return userToken;
|
||||
}
|
||||
|
||||
function insightSeries(
|
||||
data: { name?: string; values?: { value?: number; end_time?: string }[] }[] | undefined,
|
||||
name: string
|
||||
): DayPoint[] {
|
||||
const row = data?.find((d) => d.name === name);
|
||||
if (!row?.values?.length) return [];
|
||||
return row.values
|
||||
.map((v) => ({
|
||||
day: (v.end_time || '').slice(0, 10),
|
||||
value: typeof v.value === 'number' && Number.isFinite(v.value) ? v.value : 0,
|
||||
}))
|
||||
.filter((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.day))
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
/** Meta IG: many metrics only return total_value (not daily values[]). */
|
||||
function totalValueOf(
|
||||
data: { name?: string; total_value?: { value?: number } }[] | undefined,
|
||||
name: string
|
||||
): number | null {
|
||||
const row = data?.find((d) => d.name === name);
|
||||
const v = row?.total_value?.value;
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function eachDay(since: string, until: string): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${since}T12:00:00Z`);
|
||||
const end = new Date(`${until}T12:00:00Z`);
|
||||
while (cur <= end) {
|
||||
out.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastValue(series: DayPoint[]): number | null {
|
||||
if (!series.length) return null;
|
||||
return series[series.length - 1].value;
|
||||
}
|
||||
|
||||
function sumSeries(series: DayPoint[]): number {
|
||||
return series.reduce((a, p) => a + (p.value || 0), 0);
|
||||
}
|
||||
|
||||
function toUnix(isoDay: string, endOfDay = false): number {
|
||||
const t = Date.parse(`${isoDay}T${endOfDay ? '23:59:59' : '00:00:00'}Z`);
|
||||
return Math.floor(t / 1000);
|
||||
}
|
||||
|
||||
async function graphGet(
|
||||
path: string,
|
||||
token: string,
|
||||
query: Record<string, string> = {}
|
||||
): Promise<{ ok: boolean; json: Record<string, unknown>; raw: string }> {
|
||||
const q = new URLSearchParams({ ...query, access_token: token });
|
||||
const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' });
|
||||
const raw = await r.text();
|
||||
let json: Record<string, unknown> = {};
|
||||
try {
|
||||
json = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
/* keep empty */
|
||||
}
|
||||
return { ok: r.ok, json, raw };
|
||||
}
|
||||
|
||||
/** Pick first agency token that can read the page (or just /me). */
|
||||
async function pickWorkingToken(
|
||||
tokens: string[],
|
||||
pageId?: string | null
|
||||
): Promise<string | null> {
|
||||
for (const t of tokens) {
|
||||
if (pageId) {
|
||||
const probe = await graphGet(encodeURIComponent(pageId), t, {
|
||||
fields: 'id,name',
|
||||
});
|
||||
if (probe.ok) return t;
|
||||
continue;
|
||||
}
|
||||
const me = await graphGet('me', t, { fields: 'id,name' });
|
||||
if (me.ok) return t;
|
||||
}
|
||||
return tokens[0] || null;
|
||||
}
|
||||
|
||||
async function fetchPageBundle(
|
||||
agencyToken: string,
|
||||
pageId: string,
|
||||
since: string,
|
||||
until: string
|
||||
) {
|
||||
const token = await resolvePageAccessToken(agencyToken, pageId);
|
||||
const profileRes = await graphGet(encodeURIComponent(pageId), token, {
|
||||
fields: 'name,fan_count,followers_count,link,picture{url},engagement,about,website',
|
||||
});
|
||||
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
|
||||
const profile = profileRes.json as Record<string, unknown>;
|
||||
|
||||
const sinceU = String(toUnix(since));
|
||||
const untilU = String(toUnix(until, true));
|
||||
|
||||
// Meta deprecated page_impressions / page_fans (Nov 2025).
|
||||
// Use page_media_view + page_follows (+ unique views).
|
||||
const metricSets = [
|
||||
'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total,page_daily_follows,page_actions_post_reactions_total',
|
||||
'page_media_view,page_total_media_view_unique,page_post_engagements,page_follows,page_views_total',
|
||||
'page_media_view,page_follows,page_views_total',
|
||||
'page_follows,page_views_total',
|
||||
];
|
||||
|
||||
let insightData: {
|
||||
name?: string;
|
||||
values?: { value?: number; end_time?: string }[];
|
||||
}[] = [];
|
||||
let insightsError: string | undefined;
|
||||
|
||||
for (const metrics of metricSets) {
|
||||
const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, {
|
||||
metric: metrics,
|
||||
period: 'day',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
insightData = ins.json.data as typeof insightData;
|
||||
break;
|
||||
}
|
||||
insightsError = formatGraphError(ins.raw);
|
||||
}
|
||||
|
||||
// If bulk failed, try metrics one-by-one so one deprecated name doesn't wipe the rest
|
||||
if (!insightData.length) {
|
||||
const singles = [
|
||||
'page_media_view',
|
||||
'page_total_media_view_unique',
|
||||
'page_post_engagements',
|
||||
'page_follows',
|
||||
'page_views_total',
|
||||
'page_daily_follows',
|
||||
'page_actions_post_reactions_total',
|
||||
];
|
||||
const merged: typeof insightData = [];
|
||||
for (const metric of singles) {
|
||||
const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, {
|
||||
metric,
|
||||
period: 'day',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
merged.push(...(ins.json.data as typeof insightData));
|
||||
}
|
||||
}
|
||||
if (merged.length) {
|
||||
insightData = merged;
|
||||
insightsError = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const series = {
|
||||
impressions: insightSeries(insightData, 'page_media_view').length
|
||||
? insightSeries(insightData, 'page_media_view')
|
||||
: insightSeries(insightData, 'page_impressions'),
|
||||
reach: insightSeries(insightData, 'page_total_media_view_unique').length
|
||||
? insightSeries(insightData, 'page_total_media_view_unique')
|
||||
: insightSeries(insightData, 'page_impressions_unique'),
|
||||
postEngagements: insightSeries(insightData, 'page_post_engagements'),
|
||||
fans: insightSeries(insightData, 'page_follows').length
|
||||
? insightSeries(insightData, 'page_follows')
|
||||
: insightSeries(insightData, 'page_fans'),
|
||||
pageViews: insightSeries(insightData, 'page_views_total'),
|
||||
follows: insightSeries(insightData, 'page_daily_follows').length
|
||||
? insightSeries(insightData, 'page_daily_follows')
|
||||
: insightSeries(insightData, 'page_follows'),
|
||||
reactions: insightSeries(insightData, 'page_actions_post_reactions_total'),
|
||||
};
|
||||
|
||||
const pageInsights = {
|
||||
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
||||
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||
postEngagements:
|
||||
sumSeries(series.postEngagements) || lastValue(series.postEngagements),
|
||||
fans:
|
||||
lastValue(series.fans) ??
|
||||
(typeof profile.followers_count === 'number'
|
||||
? profile.followers_count
|
||||
: typeof profile.fan_count === 'number'
|
||||
? profile.fan_count
|
||||
: null),
|
||||
pageViews: sumSeries(series.pageViews) || lastValue(series.pageViews),
|
||||
follows: sumSeries(series.follows) || lastValue(series.follows),
|
||||
reactions: sumSeries(series.reactions) || lastValue(series.reactions),
|
||||
};
|
||||
|
||||
const posts: SocialPost[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const postsRes = await graphGet(
|
||||
`${encodeURIComponent(pageId)}/published_posts`,
|
||||
token,
|
||||
{
|
||||
// No pedir likes.summary / comments.summary: Meta responde (#10)
|
||||
// pages_read_engagement / Page Public Content Access aunque el permiso
|
||||
// figure como granted en el user token.
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
}
|
||||
);
|
||||
let postsError: string | undefined;
|
||||
if (postsRes.ok) {
|
||||
const data = (postsRes.json.data as Record<string, unknown>[]) || [];
|
||||
for (const p of data) {
|
||||
pushFbPost(posts, seen, p);
|
||||
}
|
||||
} else {
|
||||
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
});
|
||||
if (posts2.ok) {
|
||||
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
||||
for (const p of data) pushFbPost(posts, seen, p);
|
||||
} else {
|
||||
postsError = formatGraphError(postsRes.raw);
|
||||
}
|
||||
}
|
||||
|
||||
const videosRes = await graphGet(`${encodeURIComponent(pageId)}/videos`, token, {
|
||||
fields: 'id,description,created_time,source,permalink_url,picture,length,title',
|
||||
limit: '10',
|
||||
});
|
||||
if (videosRes.ok) {
|
||||
for (const v of (videosRes.json.data as Record<string, unknown>[]) || []) {
|
||||
if (!v?.id || seen.has(String(v.id))) continue;
|
||||
posts.push({
|
||||
id: String(v.id),
|
||||
platform: 'facebook',
|
||||
message: String(v.description || v.title || ''),
|
||||
mediaUrl: (v.source as string) || (v.picture as string) || null,
|
||||
thumbnailUrl: (v.picture as string) || null,
|
||||
permalink: (v.permalink_url as string) || null,
|
||||
createdAt: (v.created_time as string) || null,
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
mediaType: 'VIDEO',
|
||||
});
|
||||
seen.add(String(v.id));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
pageInsights,
|
||||
series,
|
||||
posts,
|
||||
postsError,
|
||||
insightsError: insightData.length ? undefined : insightsError,
|
||||
since,
|
||||
until,
|
||||
};
|
||||
}
|
||||
|
||||
function pushFbPost(
|
||||
posts: SocialPost[],
|
||||
seen: Set<string>,
|
||||
p: Record<string, unknown>
|
||||
) {
|
||||
const id = String(p.id || '');
|
||||
if (!id || seen.has(id)) return;
|
||||
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
|
||||
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
||||
const media = att?.media as { source?: string; image?: { src?: string } } | undefined;
|
||||
const subs =
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: {
|
||||
media_type?: string;
|
||||
type?: string;
|
||||
media?: { source?: string; image?: { src?: string } };
|
||||
url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const videoSrc =
|
||||
media?.source ||
|
||||
(att?.url as string) ||
|
||||
subs[0]?.media?.source ||
|
||||
null;
|
||||
const isVideo =
|
||||
attType.includes('video') ||
|
||||
String(p.status_type || '').includes('video') ||
|
||||
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
||||
const isAlbum = attType.includes('album') || subs.length > 1;
|
||||
const thumb =
|
||||
(p.full_picture as string) ||
|
||||
media?.image?.src ||
|
||||
subs[0]?.media?.image?.src ||
|
||||
null;
|
||||
const children = isAlbum
|
||||
? subs.map((s, i) => {
|
||||
const v = s.media?.source;
|
||||
const img = s.media?.image?.src || s.url || null;
|
||||
const t = String(s.media_type || s.type || '').toLowerCase();
|
||||
return {
|
||||
id: `${id}-slide-${i}`,
|
||||
mediaType: t.includes('video') ? 'VIDEO' : 'IMAGE',
|
||||
mediaUrl: v || img,
|
||||
thumbnailUrl: img,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
||||
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
||||
const shares = p.shares as { count?: number } | undefined;
|
||||
posts.push({
|
||||
id,
|
||||
platform: 'facebook',
|
||||
message: String(p.message || ''),
|
||||
mediaUrl: isVideo ? videoSrc || thumb : thumb || children?.[0]?.mediaUrl || null,
|
||||
thumbnailUrl: thumb,
|
||||
permalink: (p.permalink_url as string) || null,
|
||||
createdAt: (p.created_time as string) || null,
|
||||
likes: Number(likes?.summary?.total_count || 0),
|
||||
comments: Number(comments?.summary?.total_count || 0),
|
||||
shares: Number(shares?.count || 0),
|
||||
mediaType: isVideo ? 'VIDEO' : isAlbum ? 'CAROUSEL_ALBUM' : thumb ? 'IMAGE' : 'status',
|
||||
children,
|
||||
});
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
async function fetchIgBundle(
|
||||
agencyToken: string,
|
||||
igId: string,
|
||||
since: string,
|
||||
until: string
|
||||
) {
|
||||
const profileRes = await graphGet(encodeURIComponent(igId), agencyToken, {
|
||||
fields:
|
||||
'username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website',
|
||||
});
|
||||
if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) };
|
||||
const profile = profileRes.json as Record<string, unknown>;
|
||||
|
||||
const sinceU = String(toUnix(since));
|
||||
const untilU = String(toUnix(until, true));
|
||||
|
||||
// Time-series metrics (daily values[])
|
||||
let seriesData: {
|
||||
name?: string;
|
||||
values?: { value?: number; end_time?: string }[];
|
||||
}[] = [];
|
||||
for (const metrics of ['reach,follower_count', 'reach', 'follower_count']) {
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: metrics,
|
||||
period: 'day',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
seriesData = ins.json.data as typeof seriesData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// total_value metrics (period totals + optional per-day series)
|
||||
const totalMetrics = [
|
||||
'views',
|
||||
'profile_views',
|
||||
'website_clicks',
|
||||
'accounts_engaged',
|
||||
'total_interactions',
|
||||
];
|
||||
let totalData: {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[] = [];
|
||||
{
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
totalData = ins.json.data as typeof totalData;
|
||||
} else {
|
||||
for (const metric of totalMetrics) {
|
||||
const one = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric,
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
}
|
||||
);
|
||||
if (one.ok && Array.isArray(one.json.data)) {
|
||||
totalData.push(...(one.json.data as typeof totalData));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Daily series for total_value metrics (Meta only returns a single total per range)
|
||||
const days = eachDay(since, until).slice(-31);
|
||||
const dailyMaps: Record<string, DayPoint[]> = {
|
||||
views: [],
|
||||
profile_views: [],
|
||||
website_clicks: [],
|
||||
accounts_engaged: [],
|
||||
total_interactions: [],
|
||||
};
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const chunk = days.slice(i, i + 7);
|
||||
await Promise.all(
|
||||
chunk.map(async (day) => {
|
||||
const s = String(toUnix(day, false));
|
||||
const u = String(toUnix(day, true));
|
||||
const ins = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: s,
|
||||
until: u,
|
||||
}
|
||||
);
|
||||
if (!ins.ok || !Array.isArray(ins.json.data)) return;
|
||||
for (const row of ins.json.data as {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[]) {
|
||||
const name = row.name || '';
|
||||
if (!(name in dailyMaps)) continue;
|
||||
const v = row.total_value?.value;
|
||||
if (typeof v !== 'number') continue;
|
||||
dailyMaps[name].push({ day, value: v });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const k of Object.keys(dailyMaps)) {
|
||||
dailyMaps[k].sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
const series = {
|
||||
reach: insightSeries(seriesData, 'reach'),
|
||||
impressions: dailyMaps.views,
|
||||
profileViews: dailyMaps.profile_views,
|
||||
websiteClicks: dailyMaps.website_clicks,
|
||||
accountsEngaged: dailyMaps.accounts_engaged,
|
||||
followerDelta: insightSeries(seriesData, 'follower_count'),
|
||||
interactions: dailyMaps.total_interactions,
|
||||
};
|
||||
|
||||
const igInsights = {
|
||||
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||
impressions:
|
||||
totalValueOf(totalData, 'views') ??
|
||||
(sumSeries(series.impressions) || lastValue(series.impressions)),
|
||||
profileViews:
|
||||
totalValueOf(totalData, 'profile_views') ??
|
||||
(sumSeries(series.profileViews) || lastValue(series.profileViews)),
|
||||
websiteClicks:
|
||||
totalValueOf(totalData, 'website_clicks') ??
|
||||
(sumSeries(series.websiteClicks) || lastValue(series.websiteClicks)),
|
||||
accountsEngaged:
|
||||
totalValueOf(totalData, 'accounts_engaged') ??
|
||||
(sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged)),
|
||||
followerDelta:
|
||||
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
||||
interactions:
|
||||
totalValueOf(totalData, 'total_interactions') ??
|
||||
(sumSeries(series.interactions) || lastValue(series.interactions)),
|
||||
};
|
||||
|
||||
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
||||
fields:
|
||||
'id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
|
||||
limit: '30',
|
||||
});
|
||||
const posts: SocialPost[] = [];
|
||||
let postsError: string | undefined;
|
||||
if (mediaRes.ok) {
|
||||
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
||||
const mediaType = (m.media_type as string) || null;
|
||||
const mediaProductType = (m.media_product_type as string) || null;
|
||||
const video = isVideoType(mediaType) || mediaProductType === 'REELS';
|
||||
const childrenRaw =
|
||||
(
|
||||
m.children as {
|
||||
data?: {
|
||||
id?: string;
|
||||
media_type?: string;
|
||||
media_url?: string;
|
||||
thumbnail_url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const children = childrenRaw.map((c) => ({
|
||||
id: c.id,
|
||||
mediaType: c.media_type || null,
|
||||
mediaUrl: c.media_url || null,
|
||||
thumbnailUrl: c.thumbnail_url || null,
|
||||
}));
|
||||
const mediaUrl = video
|
||||
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
||||
: (m.media_url as string) ||
|
||||
children[0]?.mediaUrl ||
|
||||
(m.thumbnail_url as string) ||
|
||||
null;
|
||||
const thumbnailUrl =
|
||||
(m.thumbnail_url as string) ||
|
||||
(video ? null : (m.media_url as string)) ||
|
||||
children[0]?.thumbnailUrl ||
|
||||
children[0]?.mediaUrl ||
|
||||
null;
|
||||
let reach: number | null = null;
|
||||
let impressions: number | null = null;
|
||||
let shares = 0;
|
||||
try {
|
||||
const metrics = video
|
||||
? 'reach,saved,shares,views'
|
||||
: 'reach,views,saved,shares';
|
||||
const mi = await graphGet(
|
||||
`${encodeURIComponent(String(m.id))}/insights`,
|
||||
agencyToken,
|
||||
{ metric: metrics }
|
||||
);
|
||||
if (mi.ok && Array.isArray(mi.json.data)) {
|
||||
const data = mi.json.data as {
|
||||
name?: string;
|
||||
values?: { value?: number }[];
|
||||
}[];
|
||||
const val = (name: string) => {
|
||||
const row = data.find((d) => d.name === name);
|
||||
const v = row?.values?.[0]?.value;
|
||||
return typeof v === 'number' ? v : null;
|
||||
};
|
||||
reach = val('reach');
|
||||
impressions = val('views') ?? val('impressions');
|
||||
shares = val('shares') ?? 0;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
posts.push({
|
||||
id: String(m.id),
|
||||
platform: 'instagram',
|
||||
message: String(m.caption || ''),
|
||||
mediaUrl,
|
||||
thumbnailUrl,
|
||||
permalink: (m.permalink as string) || null,
|
||||
createdAt: (m.timestamp as string) || null,
|
||||
likes: Number(m.like_count || 0),
|
||||
comments: Number(m.comments_count || 0),
|
||||
shares,
|
||||
reach,
|
||||
impressions,
|
||||
mediaType:
|
||||
mediaProductType === 'REELS'
|
||||
? 'REELS'
|
||||
: mediaType || (children.length > 1 ? 'CAROUSEL_ALBUM' : null),
|
||||
mediaProductType,
|
||||
children: children.length ? children : undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
postsError = formatGraphError(mediaRes.raw);
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
igInsights,
|
||||
series,
|
||||
posts,
|
||||
postsError,
|
||||
insightsError: undefined,
|
||||
since,
|
||||
until,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultRange(): { since: string; until: string } {
|
||||
const until = new Date();
|
||||
until.setUTCDate(until.getUTCDate() - 1);
|
||||
const since = new Date(until);
|
||||
since.setUTCDate(since.getUTCDate() - 27);
|
||||
return {
|
||||
since: since.toISOString().slice(0, 10),
|
||||
until: until.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET — social integrations + analytics series + posts. ?since=&until= */
|
||||
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({
|
||||
integrations: [],
|
||||
metrics: {},
|
||||
posts: [],
|
||||
warning: 'Selecciona un cliente en Contexto',
|
||||
});
|
||||
}
|
||||
|
||||
const def = defaultRange();
|
||||
const sinceParam = req.nextUrl.searchParams.get('since') || '';
|
||||
const untilParam = req.nextUrl.searchParams.get('until') || '';
|
||||
const since = /^\d{4}-\d{2}-\d{2}$/.test(sinceParam) ? sinceParam : def.since;
|
||||
const until = /^\d{4}-\d{2}-\d{2}$/.test(untilParam) ? untilParam : def.until;
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const providers = 'instagram,facebook,tiktok,linkedin';
|
||||
let integrations: Integ[] = [];
|
||||
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
const r = await restGet(
|
||||
`/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message`
|
||||
);
|
||||
integrations = (r.data as Integ[]) || [];
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('integrations')
|
||||
.select(
|
||||
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
|
||||
)
|
||||
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
|
||||
integrations = (data as Integ[]) || [];
|
||||
}
|
||||
|
||||
const metrics: Record<string, unknown> = {};
|
||||
const posts: SocialPost[] = [];
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
|
||||
const fbInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'facebook' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
const igInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'instagram' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
|
||||
if (!tokens.length) {
|
||||
metrics.warning =
|
||||
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram) metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
metrics.warning +=
|
||||
' Mostrando último snapshot cacheado.';
|
||||
}
|
||||
} else {
|
||||
const token =
|
||||
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
|
||||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
|
||||
tokens[0];
|
||||
|
||||
try {
|
||||
if (fbInteg?.external_account_id) {
|
||||
const fb = await fetchPageBundle(
|
||||
token,
|
||||
fbInteg.external_account_id,
|
||||
since,
|
||||
until
|
||||
);
|
||||
metrics.facebook = fb;
|
||||
if ('posts' in fb && Array.isArray(fb.posts)) {
|
||||
posts.push(...(fb.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
|
||||
}
|
||||
if (igInteg?.external_account_id) {
|
||||
const ig = await fetchIgBundle(
|
||||
token,
|
||||
igInteg.external_account_id,
|
||||
since,
|
||||
until
|
||||
);
|
||||
metrics.instagram = ig;
|
||||
if ('posts' in ig && Array.isArray(ig.posts)) {
|
||||
posts.push(...(ig.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.warning = e instanceof Error ? e.message : 'metrics error';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram && !metrics.instagram)
|
||||
metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter posts to selected range when dates exist
|
||||
const ranged = posts.filter((p) => {
|
||||
if (!p.createdAt) return true;
|
||||
const d = p.createdAt.slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
ranged.sort((a, b) => {
|
||||
const ta = Date.parse(a.createdAt || '') || 0;
|
||||
const tb = Date.parse(b.createdAt || '') || 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
integrations,
|
||||
metrics,
|
||||
posts: ranged.slice(0, 24),
|
||||
since,
|
||||
until,
|
||||
label: ctx.label,
|
||||
});
|
||||
}
|
||||
451
src/app/api/webhooks/clientify/route.ts
Normal file
451
src/app/api/webhooks/clientify/route.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Clientify outbound webhooks.
|
||||
*
|
||||
* Paste in Clientify → Integraciones → Webhooks (per subcuenta):
|
||||
* https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia
|
||||
*
|
||||
* Optional secret (same for all entities):
|
||||
* &secret=YOUR_SECRET (must match CLIENTIFY_WEBHOOK_SECRET)
|
||||
*
|
||||
* Entity-specific (optional, inferred if omitted):
|
||||
* &entity=contact|company|deal|activity|budget|product
|
||||
*/
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
function n(v: unknown): number {
|
||||
const x = Number(v);
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function str(v: unknown, max = 200): string | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'object' && v !== null && 'name' in (v as object)) {
|
||||
return String((v as { name?: unknown }).name || '').slice(0, max) || null;
|
||||
}
|
||||
const s = String(v).trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function uuid5(name: string): string {
|
||||
// RFC 4122 UUID v5 with URL namespace (same as Python uuid.uuid5(NAMESPACE_URL, …))
|
||||
const NS = Buffer.from('6ba7b8119dad11d180b400c04fd430c8', 'hex');
|
||||
const hash = createHash('sha1').update(NS).update(name).digest();
|
||||
hash[6] = (hash[6]! & 0x0f) | 0x50;
|
||||
hash[8] = (hash[8]! & 0x3f) | 0x80;
|
||||
const h = hash.subarray(0, 16).toString('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function firstEmail(c: Json): string | null {
|
||||
const emails = c.emails;
|
||||
if (Array.isArray(emails) && emails.length) {
|
||||
const e = emails[0] as Json;
|
||||
return str(typeof e === 'object' ? e.email || e.value : e, 200);
|
||||
}
|
||||
return str(c.email, 200);
|
||||
}
|
||||
|
||||
function firstPhone(c: Json): string | null {
|
||||
const phones = c.phones;
|
||||
if (Array.isArray(phones) && phones.length) {
|
||||
const p = phones[0] as Json;
|
||||
return str(typeof p === 'object' ? p.phone || p.value : p, 60);
|
||||
}
|
||||
return str(c.phone, 60);
|
||||
}
|
||||
|
||||
function contactIdFromUrl(url: unknown): string | null {
|
||||
const s = str(url, 300);
|
||||
if (!s) return null;
|
||||
const parts = s.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
function mapDealStage(d: Json): string {
|
||||
const desc = (str(d.status_desc, 40) || '').toLowerCase();
|
||||
if (desc === 'won') return 'won';
|
||||
if (desc === 'lost') return 'lost';
|
||||
if (desc === 'open') return 'open';
|
||||
const st = n(d.status);
|
||||
if (st === 3) return 'won';
|
||||
if (st === 4) return 'lost';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function unwrapPayload(body: unknown): Json {
|
||||
if (!body || typeof body !== 'object') return {};
|
||||
const b = body as Json;
|
||||
// Common wrappers
|
||||
for (const k of ['contact', 'deal', 'company', 'data', 'object', 'payload']) {
|
||||
const inner = b[k];
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
return inner as Json;
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function guessEntity(q: string | null, payload: Json): string {
|
||||
if (q) return q.toLowerCase();
|
||||
if (payload.pipeline != null || payload.pipeline_stage != null || payload.amount != null)
|
||||
return 'deal';
|
||||
if (payload.first_name != null || payload.emails != null || payload.phones != null)
|
||||
return 'contact';
|
||||
if (payload.company_sector != null || (payload.name != null && payload.domain != null))
|
||||
return 'company';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function resolveTenantId(slugOrId: string | null): Promise<string | null> {
|
||||
if (!slugOrId) return null;
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return null;
|
||||
const base = supabaseUrl();
|
||||
// UUID?
|
||||
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
|
||||
// Legacy slug aliases (renames)
|
||||
const SLUG_ALIASES: Record<string, string> = {
|
||||
herencias: 'fbm-abogados',
|
||||
};
|
||||
const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId;
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
const rows = (await r.json()) as { id: string }[];
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function upsertLead(tenantId: string, c: Json) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const cid = str(c.id, 40);
|
||||
if (!cid) throw new Error('contact missing id');
|
||||
const id = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
||||
const source =
|
||||
str(c.contact_source, 80) ||
|
||||
str(c.channel, 80) ||
|
||||
str(c.medium, 80) ||
|
||||
'clientify';
|
||||
const newStatus = (str(c.status, 40) || 'new').toLowerCase();
|
||||
const now = new Date().toISOString();
|
||||
const base = supabaseUrl();
|
||||
|
||||
// Preserve alta date across webhook re-writes (DELETE+INSERT).
|
||||
let prevCreated: string | null = null;
|
||||
let prevStatus: string | null = null;
|
||||
let prevStatusChanged: string | null = null;
|
||||
const prevR = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR.ok) {
|
||||
const rows = (await prevR.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
status_changed_at?: string | null;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
prevStatusChanged = rows[0].status_changed_at || null;
|
||||
}
|
||||
} else {
|
||||
// status_changed_at may not exist yet — retry without it
|
||||
const prevR2 = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR2.ok) {
|
||||
const rows = (await prevR2.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fromCfy =
|
||||
str(c.created, 40) ||
|
||||
str(c.created_at, 40) ||
|
||||
str(c.date_created, 40);
|
||||
let createdAt = prevCreated || fromCfy || now;
|
||||
// Normalize date-only
|
||||
if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`;
|
||||
|
||||
const statusChanged =
|
||||
!prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt;
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
first_name:
|
||||
[str(c.first_name, 80), str(c.last_name, 80)].filter(Boolean).join(' ') ||
|
||||
null,
|
||||
email: firstEmail(c),
|
||||
phone: firstPhone(c),
|
||||
source: source.toLowerCase(),
|
||||
medium: str(c.medium, 80),
|
||||
crm: 'clientify',
|
||||
crm_contact_id: cid,
|
||||
status: newStatus,
|
||||
created_at: createdAt,
|
||||
updated_at: now,
|
||||
status_changed_at: statusChanged,
|
||||
};
|
||||
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
|
||||
let ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
// Schema not migrated yet: drop timing columns and retry
|
||||
if (!ins.ok) {
|
||||
const err = await ins.text();
|
||||
if (err.includes('updated_at') || err.includes('status_changed_at')) {
|
||||
delete row.updated_at;
|
||||
delete row.status_changed_at;
|
||||
ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
if (!ins.ok) {
|
||||
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
return { id, crm_contact_id: cid };
|
||||
}
|
||||
|
||||
async function upsertDeal(tenantId: string, d: Json) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const did = str(d.id, 40);
|
||||
if (!did) throw new Error('deal missing id');
|
||||
const id = uuid5(`clientify-deal:${tenantId}:${did}`);
|
||||
const stage = mapDealStage(d);
|
||||
const cid = contactIdFromUrl(d.contact);
|
||||
let leadId: string | null = null;
|
||||
if (cid) {
|
||||
const base = supabaseUrl();
|
||||
const lr = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (lr.ok) {
|
||||
const rows = (await lr.json()) as { id: string }[];
|
||||
leadId = rows[0]?.id || null;
|
||||
}
|
||||
if (!leadId) {
|
||||
leadId = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
||||
}
|
||||
}
|
||||
const closed = str(d.actual_closed_date, 40);
|
||||
const pipeUrl = str(d.pipeline, 200) || '';
|
||||
let pipelineId: string | null = null;
|
||||
if (pipeUrl.includes('/pipelines/')) {
|
||||
pipelineId = pipeUrl.replace(/\/$/, '').split('/').pop() || null;
|
||||
}
|
||||
const tags = Array.isArray(d.tags) ? d.tags.map(String) : [];
|
||||
const tagBlob = tags.join(' ').toLowerCase();
|
||||
const dealName = str(d.name, 200) || '';
|
||||
let source =
|
||||
str(d.deal_source, 80) ||
|
||||
str(d.contact_source, 80) ||
|
||||
'';
|
||||
if (!source) {
|
||||
if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tagBlob)) source = 'meta';
|
||||
else if (/google/.test(tagBlob)) source = 'google';
|
||||
else if (
|
||||
/woocommerce|ecommerce/.test(tagBlob) ||
|
||||
/^order\s+\d+/i.test(dealName)
|
||||
) {
|
||||
source = 'ecommerce';
|
||||
}
|
||||
}
|
||||
const row = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
lead_id: leadId,
|
||||
crm_deal_id: `clientify:${did}`,
|
||||
stage,
|
||||
amount: n(d.amount ?? d.amount_user),
|
||||
won_at: stage === 'won' && closed ? closed : null,
|
||||
lost_at: stage === 'lost' && closed ? closed : null,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: str(d.pipeline_desc, 120) || null,
|
||||
source: source ? source.toLowerCase() : null,
|
||||
name: dealName || null,
|
||||
};
|
||||
const base = supabaseUrl();
|
||||
await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tenantId)}&crm_deal_id=eq.${encodeURIComponent(`clientify:${did}`)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
const ins = await fetch(`${base}/rest/v1/deals`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
throw new Error(`deal insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
return { id, crm_deal_id: row.crm_deal_id, stage };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const tenant = req.nextUrl.searchParams.get('tenant');
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: 'clientify-webhook',
|
||||
tenant: tenant || null,
|
||||
hint: 'POST events from Clientify. Use ?tenant=<slug>',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&secret=…',
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
|
||||
const secretQ = req.nextUrl.searchParams.get('secret');
|
||||
const secretH = req.headers.get('x-clientify-secret');
|
||||
// Obligatorio: sin secreto el webhook es inyectable desde fuera
|
||||
if (!secretEnv) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'webhook_not_configured',
|
||||
hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantParam =
|
||||
req.nextUrl.searchParams.get('tenant') ||
|
||||
req.nextUrl.searchParams.get('tenant_id');
|
||||
const tenantId = await resolveTenantId(tenantParam);
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_or_unknown_tenant',
|
||||
hint: 'Add ?tenant=<slug>&secret=… to the webhook URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = unwrapPayload(body);
|
||||
const entity = guessEntity(
|
||||
req.nextUrl.searchParams.get('entity'),
|
||||
payload
|
||||
);
|
||||
|
||||
try {
|
||||
if (entity === 'contact') {
|
||||
const r = await upsertLead(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
if (entity === 'deal') {
|
||||
const r = await upsertDeal(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
// company/activity/budget/product: acknowledge for now
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
entity,
|
||||
ignored: true,
|
||||
id: payload.id ?? randomUUID(),
|
||||
hint: 'contact/deal only persisted in v1',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'error';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */
|
||||
async function markClientifyConnected(
|
||||
tenantId: string,
|
||||
slugHint: string | null
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const base = supabaseUrl();
|
||||
const now = new Date().toISOString();
|
||||
const body = {
|
||||
status: 'connected',
|
||||
enabled: true,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
label: 'Clientify',
|
||||
external_account_id: slugHint || undefined,
|
||||
meta: {
|
||||
source: 'webhook',
|
||||
synced_at: now,
|
||||
},
|
||||
};
|
||||
const patch = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (patch.ok) return;
|
||||
await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=ignore-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider: 'clientify',
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,93 +1,36 @@
|
||||
import React from 'react';
|
||||
import { FileText, CheckCircle2 } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import AssetsLibrary from '@/components/AssetsLibrary';
|
||||
import { EmptyState, PageHeader } from '@/components/ui';
|
||||
|
||||
export const revalidate = 0;
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ActivosPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
let documents: Array<{
|
||||
id: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
version?: number;
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
}> = [];
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
|
||||
if (user) {
|
||||
// Relies on RLS: only documents for the caller's tenant
|
||||
const { data, error } = await supabase
|
||||
.from('documents')
|
||||
.select('id,type,title,content,version,created_at,status')
|
||||
.eq('status', 'approved')
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) {
|
||||
console.error('Error fetching documents:', error.message);
|
||||
} else {
|
||||
documents = data ?? [];
|
||||
}
|
||||
if (ctx.portfolioWide) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Contenido"
|
||||
title="Activos"
|
||||
description="Biblioteca de archivos validados por cliente."
|
||||
/>
|
||||
<EmptyState
|
||||
title="Elige un cliente"
|
||||
description="Selecciona un tenant en Contexto para subir o consultar archivos."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white">
|
||||
Biblioteca de Activos & Entregables
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Documentos, copys, calendarios y briefs producidos por los agentes de IA
|
||||
y aprobados por el equipo en Slack. Filtrado por tenant (RLS).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{documents.length === 0 ? (
|
||||
<div className="col-span-3 bg-[#111827] border border-gray-800 rounded-xl p-12 text-center text-gray-400">
|
||||
<FileText className="w-10 h-10 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-base font-medium text-gray-300">
|
||||
No hay activos aprobados todavía.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Los activos generados por los agentes aparecerán aquí tras ser
|
||||
validados en Slack.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="bg-[#111827] border border-gray-800 rounded-xl p-5 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="uppercase text-[10px] font-bold px-2 py-0.5 rounded bg-gray-800 text-gray-300">
|
||||
{doc.type}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[11px] text-emerald-400">
|
||||
<CheckCircle2 className="w-3 h-3" /> Aprobado
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-bold text-white text-base mb-2">{doc.title}</h4>
|
||||
<p className="text-xs text-gray-400 line-clamp-4">{doc.content}</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-800/80 mt-4 text-[11px] text-gray-400 flex justify-between">
|
||||
<span>Versión v{doc.version}</span>
|
||||
<span>
|
||||
{doc.created_at
|
||||
? new Date(doc.created_at).toLocaleDateString('es-ES')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AssetsLibrary tenantLabel={ctx.label} />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user