Compare commits
155 Commits
8c5acb626f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
812052b7eb | ||
|
|
4c91c4b1f4 | ||
|
|
ee6e6bace1 | ||
|
|
f127b0a4c4 | ||
|
|
5de5e3c5e0 | ||
|
|
0742add9e6 | ||
|
|
5c76ae18bb | ||
|
|
77fd628078 | ||
|
|
c434fa5834 | ||
|
|
6763613ed4 | ||
|
|
4a1abd3318 | ||
|
|
3b57cdce53 | ||
|
|
39b1f8ec02 | ||
|
|
62a25e7236 | ||
|
|
1f6c16a01b | ||
|
|
89869c0e7c | ||
|
|
d049fa0f3b | ||
|
|
49d3529525 | ||
|
|
de546e00e0 | ||
|
|
0b3f80d790 | ||
|
|
3cb3ad1db1 | ||
|
|
102c100427 | ||
|
|
4f8ea26938 | ||
|
|
9a4ed8b66c | ||
|
|
f476f6a291 | ||
|
|
d4f28affc4 | ||
|
|
0ddd5e5907 | ||
|
|
e4a605bdbd | ||
|
|
bcde8d8198 | ||
|
|
b08c4ca00d | ||
|
|
85e9f4c184 | ||
|
|
8b101c094c | ||
|
|
6e8e9ee5c8 | ||
|
|
9d7d799ec1 | ||
|
|
0c1ff3cc67 | ||
|
|
aed73e0171 | ||
|
|
feef339c2b | ||
|
|
0e8befe69a | ||
|
|
f9dddd5bf9 | ||
|
|
a225f8c2a6 | ||
|
|
fd2bd9582d | ||
|
|
d3e383dd08 | ||
|
|
1cf8e7c1bd | ||
|
|
c8608f36f5 | ||
|
|
f80292cc1b | ||
|
|
2b57b93220 | ||
|
|
714f250eca | ||
|
|
3afec6402f | ||
|
|
46492c3873 | ||
|
|
4bc9ed7820 | ||
|
|
7819448edc | ||
|
|
b13270d9ac | ||
|
|
142933a654 | ||
|
|
2a1629cda8 | ||
|
|
66d34853e6 | ||
|
|
ab64ead6c3 | ||
|
|
9276dba00d | ||
|
|
317a3aa46d | ||
|
|
34ce46774d | ||
|
|
7b604c68ca | ||
|
|
cf34868bc3 | ||
|
|
487895a420 | ||
|
|
a7e2723a3e | ||
|
|
52cebab3e6 | ||
|
|
a17b1eae56 | ||
|
|
958c59a8ec | ||
|
|
7b702cd4cc | ||
|
|
0f9f03fad5 | ||
|
|
9f1721c921 | ||
|
|
b3936932d3 | ||
|
|
503f8322d1 | ||
|
|
e650c9971e | ||
|
|
62d1f3a5f6 | ||
|
|
f08bcf2bb8 | ||
|
|
3cb3296518 | ||
|
|
1715b0ac67 | ||
|
|
e49111f63e | ||
|
|
59f42eafe0 | ||
|
|
8723753066 | ||
|
|
f316497650 | ||
|
|
501f8203e8 | ||
|
|
025fc85175 | ||
|
|
67969b12cb | ||
|
|
71110915af | ||
|
|
4baf256750 | ||
|
|
b4e843762e | ||
|
|
b53161dc4d | ||
|
|
8ef21d98d7 | ||
|
|
e3fe157fa5 | ||
|
|
83de3fb3aa | ||
|
|
2759aa87f6 | ||
|
|
26dc411647 | ||
|
|
0d65614518 | ||
|
|
233d3f202e | ||
|
|
5b895d830b | ||
|
|
b4d86e1e28 | ||
|
|
ade2762b01 | ||
|
|
c2bbbfcd06 | ||
|
|
12ecde1b3b | ||
|
|
392af11060 | ||
|
|
91380e2285 | ||
|
|
d073df12d1 | ||
|
|
b395243ce7 | ||
|
|
ed275f475a | ||
|
|
a5bfec698b | ||
|
|
fac9867202 | ||
|
|
965a58540e | ||
|
|
d411de0086 | ||
|
|
b9ddc7b806 | ||
|
|
8db642fa2a | ||
|
|
dfb44d240e | ||
|
|
4d662430c0 | ||
|
|
f19a54ca42 | ||
|
|
8a9d92cb6b | ||
|
|
81b424bd2f | ||
|
|
ead310e783 | ||
|
|
5b21ffce94 | ||
|
|
ed52bf99c4 | ||
|
|
0ad89e88e5 | ||
|
|
487e2c7a2b | ||
|
|
fb8adab162 | ||
|
|
ace73c2699 | ||
|
|
3ddf722912 | ||
|
|
78bd80079d | ||
|
|
ce6dc4e0f0 | ||
|
|
f3380b9650 | ||
|
|
c82cc32278 | ||
|
|
1f9bc38af0 | ||
|
|
bb5f721b38 | ||
|
|
a6509bb89c | ||
|
|
ec7f1f05de | ||
|
|
821519611d | ||
|
|
777c4f9dfa | ||
|
|
12f9c3cd1e | ||
|
|
36d9e059a3 | ||
|
|
6e81302c89 | ||
|
|
669b9dd056 | ||
|
|
d56ba63f17 | ||
|
|
fff5bf1ca2 | ||
|
|
630a70c533 | ||
|
|
3b8c41c7fb | ||
|
|
f70d366a7f | ||
|
|
6bfd8a117f | ||
|
|
38bb161531 | ||
|
|
4bf3ecb744 | ||
|
|
5124c1d8a4 | ||
|
|
e07f21c674 | ||
|
|
e84310ec52 | ||
|
|
2f6e50d5a7 | ||
|
|
a3a2e4b62b | ||
|
|
6566b42037 | ||
|
|
ed18c9d318 | ||
|
|
e3180ceb51 | ||
|
|
c11ec20f26 | ||
|
|
1a58af129d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -60,3 +60,5 @@ yarn-error.log*
|
|||||||
*.local
|
*.local
|
||||||
coverage/
|
coverage/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
.ops/gitea-deploy/
|
||||||
|
|||||||
370
docs/API_INTEGRACIONES.md
Normal file
370
docs/API_INTEGRACIONES.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# Especificación y Contrato de API — Integraciones n8n y Claude
|
||||||
|
|
||||||
|
Esta documentación describe todos los endpoints disponibles en `https://app.4ventures.es/api/integrations/...` para la ingesta de métricas de Meta Ads, detección de fatiga, relevo creativo con Claude, aprobaciones y publicación.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Autenticación y Seguridad
|
||||||
|
|
||||||
|
Todas las peticiones a la API de integraciones deben incluir la cabecera `X-Api-Key`:
|
||||||
|
- **Para n8n:** Clave asignada en `N8N_API_KEY`
|
||||||
|
- **Para Claude:** Clave asignada en `CLAUDE_API_KEY`
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Api-Key: <TU_API_KEY>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Códigos de estado HTTP globales
|
||||||
|
- `200 OK`: Petición procesada correctamente.
|
||||||
|
- `400 Bad Request`: Parámetros obligatorios faltantes o payload JSON malformado.
|
||||||
|
- `401 Unauthorized`: Cabecera `X-Api-Key` ausente o no válida.
|
||||||
|
- `403 Forbidden`: El cliente (`client_id` / `tenant_id`) no tiene activo el flag `creatives_enabled = true`.
|
||||||
|
- `404 Not Found`: El cliente o recurso solicitado no existe.
|
||||||
|
- `500 Internal Server Error`: Error inesperado en el servidor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Endpoints para n8n (Ingesta, Fatiga y Publicación)
|
||||||
|
|
||||||
|
### 2.1 Obtener Clientes Activos
|
||||||
|
Obtiene la lista de clientes que tienen activado el módulo de creatividades y su cuenta de anuncios de Meta.
|
||||||
|
|
||||||
|
- **Método:** `GET`
|
||||||
|
- **Ruta:** `/api/integrations/clients?creatives_enabled=true`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"name": "Blue Parrot School",
|
||||||
|
"slug": "bps",
|
||||||
|
"meta_ad_account_id": "act_1052328112282270",
|
||||||
|
"slack_channel_id": null,
|
||||||
|
"drive_folder_id": null,
|
||||||
|
"creatives_enabled": true,
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"tenant_id": "8bec977a-6032-4759-aa7f-b5042081f450"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Ingesta Diaria de Métricas (Bulk Upsert)
|
||||||
|
Inserta o actualiza las métricas por anuncio y fecha. Si el anuncio (`meta_ad_id`) no existía en la base de datos, lo crea automáticamente en estado `publicada`.
|
||||||
|
|
||||||
|
- **Método:** `POST`
|
||||||
|
- **Ruta:** `/api/integrations/metrics/daily`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`, `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Payload de ejemplo (`Request Body`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"meta_ad_id": "1202058493829102",
|
||||||
|
"date": "2026-10-11",
|
||||||
|
"campaign_name": "Matrículas Octubre 2026",
|
||||||
|
"impressions": 4200,
|
||||||
|
"reach": 3800,
|
||||||
|
"clicks": 135,
|
||||||
|
"spend": 54.20,
|
||||||
|
"results": 6,
|
||||||
|
"raw": {
|
||||||
|
"cpm": 12.90,
|
||||||
|
"ctr": 3.21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"count": 1,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": "7e417520-6089-4cc8-9e44-f08b4e948b2a",
|
||||||
|
"meta_ad_id": "1202058493829102",
|
||||||
|
"date": "2026-10-11"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Obtener Candidatos de Fatiga
|
||||||
|
Devuelve las creatividades actualmente publicadas con sus métricas dentro de la ventana de análisis (`lookback_days`) y las reglas de fatiga del cliente.
|
||||||
|
|
||||||
|
- **Método:** `GET`
|
||||||
|
- **Ruta:** `/api/integrations/fatigue/candidates?client_id=8bec977a-6032-4759-aa7f-b5042081f450`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"tenant_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"lookback_days": 7,
|
||||||
|
"rules": {
|
||||||
|
"lookback_days": 7,
|
||||||
|
"min_impressions": 2000,
|
||||||
|
"max_frequency": 3.0,
|
||||||
|
"ctr_drop_pct": 30.0,
|
||||||
|
"cpa_rise_pct": 25.0,
|
||||||
|
"signals_required": 2,
|
||||||
|
"auto_request_relief": true
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"id": "c05d4143-f19d-421a-b8d9-9b03d6b0889a",
|
||||||
|
"meta_ad_id": "1202058493829102",
|
||||||
|
"format": "estatico",
|
||||||
|
"status": "publicada",
|
||||||
|
"metrics": [
|
||||||
|
{
|
||||||
|
"date": "2026-10-10",
|
||||||
|
"impressions": 3000,
|
||||||
|
"clicks": 90,
|
||||||
|
"spend": 45.0,
|
||||||
|
"results": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Marcar Creatividad Fatigada
|
||||||
|
Cambia el estado de la creatividad a `fatigada`, guarda el evento de auditoría y, si `auto_request_relief` está activo, crea automáticamente una petición de relevo en `generation_requests`.
|
||||||
|
|
||||||
|
- **Método:** `POST`
|
||||||
|
- **Ruta:** `/api/integrations/fatigue/mark`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`, `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Payload de ejemplo (`Request Body`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"creative_id": "c05d4143-f19d-421a-b8d9-9b03d6b0889a",
|
||||||
|
"signals": {
|
||||||
|
"ctr_drop_pct": 32.5,
|
||||||
|
"frequency": 3.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"creative_id": "c05d4143-f19d-421a-b8d9-9b03d6b0889a",
|
||||||
|
"status": "fatigada",
|
||||||
|
"signals": { "ctr_drop_pct": 32.5, "frequency": 3.4 },
|
||||||
|
"generation_request": {
|
||||||
|
"id": "377cf30e-81e5-4d80-8f4f-f30a8dd2d239",
|
||||||
|
"status": "pendiente",
|
||||||
|
"reason": "fatiga",
|
||||||
|
"brief": {
|
||||||
|
"client_slug": "bps",
|
||||||
|
"variants_requested": 2,
|
||||||
|
"avoid_angles": ["objecion_precio"],
|
||||||
|
"format": "estatico",
|
||||||
|
"aspect_ratio": "4:5",
|
||||||
|
"aspect_ratios": ["4:5", "9:16", "1.91:1"],
|
||||||
|
"required_placements": [
|
||||||
|
{ "aspect_ratio": "4:5", "role": "feed", "size": "1080x1350", "meta_key": "asset_url" },
|
||||||
|
{ "aspect_ratio": "9:16", "role": "stories", "size": "1080x1920", "meta_key": "stories_asset_url", "canva_template_key": "stories_9_16" },
|
||||||
|
{ "aspect_ratio": "1.91:1", "role": "landscape", "size": "1200x628", "meta_key": "landscape_asset_url", "canva_template_key": "landscape_1_91" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 Listar Creatividades Aprobadas para Publicar
|
||||||
|
Permite a n8n consultar las creatividades aprobadas pendientes de subirse a Meta Ads.
|
||||||
|
|
||||||
|
- **Método:** `GET`
|
||||||
|
- **Ruta:** `/api/integrations/creatives?status=aprobada&client_id=8bec977a-6032-4759-aa7f-b5042081f450`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1800fa1e-e969-469c-9913-6c973acba346",
|
||||||
|
"headline": "Aprende a tu ritmo",
|
||||||
|
"primary_text": "Clases de 25 minutos adaptadas a tu agenda laboral con nativos.",
|
||||||
|
"cta": "Empezar gratis",
|
||||||
|
"asset_url": "https://storage.4ventures.es/creatives/bps_v1.jpg",
|
||||||
|
"status": "aprobada",
|
||||||
|
"format": "estatico"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Notificar Publicación de Anuncio en Meta
|
||||||
|
Tras crear el anuncio en Meta Ads, n8n actualiza la creatividad con su identificador oficial y la pasa a `publicada`.
|
||||||
|
|
||||||
|
- **Método:** `PATCH`
|
||||||
|
- **Ruta:** `/api/integrations/creatives/:id`
|
||||||
|
- **Headers:** `X-Api-Key: <N8N_API_KEY>`, `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Payload de ejemplo (`Request Body`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"meta_ad_id": "1202059998123456",
|
||||||
|
"status": "publicada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Respuesta de ejemplo (`200 OK`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "1800fa1e-e969-469c-9913-6c973acba346",
|
||||||
|
"meta_ad_id": "1202059998123456",
|
||||||
|
"status": "publicada",
|
||||||
|
"updated_at": "2026-10-12T11:47:10.455Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoints para Claude (Generación de Relevo Creativo)
|
||||||
|
|
||||||
|
### 3.1 Obtener Contexto de la Creatividad Fatigada
|
||||||
|
Devuelve a Claude todos los datos necesarios para generar el relevo: el anuncio fatigado, sus métricas, las 3 mejores creatividades históricas del cliente y las reglas de fatiga.
|
||||||
|
|
||||||
|
- **Método:** `GET`
|
||||||
|
- **Ruta:** `/api/integrations/creatives/:id/context?client_id=8bec977a-6032-4759-aa7f-b5042081f450`
|
||||||
|
- **Headers:** `X-Api-Key: <CLAUDE_API_KEY>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Crear Variantes Generadas
|
||||||
|
Inserta las variantes generadas por Claude. Nacen en estado `borrador`, con visibilidad `agency` y `created_by = 'claude'`.
|
||||||
|
|
||||||
|
- **Método:** `POST`
|
||||||
|
- **Ruta:** `/api/integrations/creatives`
|
||||||
|
- **Headers:** `X-Api-Key: <CLAUDE_API_KEY>`, `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Payload de ejemplo:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"generation_request_id": "377cf30e-81e5-4d80-8f4f-f30a8dd2d239",
|
||||||
|
"creatives": [
|
||||||
|
{
|
||||||
|
"format": "estatico",
|
||||||
|
"angle": "objecion_horarios",
|
||||||
|
"hook": "¿Sin tiempo para aprender inglés?",
|
||||||
|
"primary_text": "Clases de 25 minutos adaptadas a tu agenda.",
|
||||||
|
"headline": "Aprende a tu ritmo",
|
||||||
|
"cta": "Empezar gratis",
|
||||||
|
"asset_url": "https://storage.4ventures.es/creatives/bps_v1.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Completar Petición de Generación
|
||||||
|
Actualiza el estado de la tarea en la cola de Claude (`completada` o `fallida`), anotando los IDs generados y el coste estimado en tokens/créditos.
|
||||||
|
|
||||||
|
- **Método:** `PATCH`
|
||||||
|
- **Ruta:** `/api/integrations/generation-requests/:id`
|
||||||
|
- **Headers:** `X-Api-Key: <CLAUDE_API_KEY>`, `Content-Type: application/json`
|
||||||
|
|
||||||
|
#### Payload de ejemplo:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "8bec977a-6032-4759-aa7f-b5042081f450",
|
||||||
|
"status": "completada",
|
||||||
|
"result": {
|
||||||
|
"creatives": ["1800fa1e-e969-469c-9913-6c973acba346"],
|
||||||
|
"notes": "Variante enfocada en flexibilidad horaria",
|
||||||
|
"skills_used": ["cliente-blue-parrot", "formato-estatico-4-5"]
|
||||||
|
},
|
||||||
|
"cost_estimate": { "tokens": 8500, "credits": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Webhooks Salientes hacia n8n
|
||||||
|
|
||||||
|
La aplicación emite llamadas HTTP POST hacia la URL configurada en `N8N_WEBHOOK_BASE` (`https://n8n-agencia.4ventures.es/webhook/creative-...`).
|
||||||
|
|
||||||
|
Cada webhook se envía con la cabecera:
|
||||||
|
```http
|
||||||
|
X-Signature: <hex_digest_hmac_sha256>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eventos emitidos:
|
||||||
|
1. `creative.approved`: Un usuario cliente o de agencia aprueba la creatividad.
|
||||||
|
2. `creative.rejected`: Un usuario rechaza la creatividad (requiere comentario obligatorio).
|
||||||
|
3. `creative.sent_to_client`: La agencia envía la creatividad al cliente para su revisión.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verificación de Firma HMAC en n8n (Nodo Code)
|
||||||
|
|
||||||
|
En el flujo de n8n que recibe el webhook (`Webhook Node`), agrega un nodo **Code** en modo `Run Once for Each Item` con el siguiente código JavaScript para verificar la autenticidad antes de procesar la petición:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// 1. Obtener la firma enviada en la cabecera
|
||||||
|
const signature = $input.item.headers['x-signature'];
|
||||||
|
if (!signature) {
|
||||||
|
throw new Error('Cabecera X-Signature no proporcionada en el Webhook');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Secreto compartido (obtenido de las credenciales de n8n o variables de entorno)
|
||||||
|
const sharedSecret = $env['N8N_WEBHOOK_SECRET'] || 'whsec_4v_7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
|
||||||
|
|
||||||
|
// 3. Obtener el cuerpo recibido (raw string exacto o serializado)
|
||||||
|
const bodyPayload = typeof $input.item.body === 'string'
|
||||||
|
? $input.item.body
|
||||||
|
: JSON.stringify($input.item.body);
|
||||||
|
|
||||||
|
// 4. Calcular el HMAC esperado
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', sharedSecret)
|
||||||
|
.update(bodyPayload)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
// 5. Comparación segura
|
||||||
|
const isValid = crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signature),
|
||||||
|
Buffer.from(expectedSignature)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Firma HMAC no válida: webhook rechazado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: {
|
||||||
|
verified: true,
|
||||||
|
event: $input.item.body.event,
|
||||||
|
data: $input.item.body.data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -67,6 +67,18 @@ Endpoints individuales (mismo secret):
|
|||||||
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
|
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
|
||||||
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
|
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
|
||||||
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
|
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
|
||||||
|
- `/api/cron/clientify-sync` — reconciliación CRM Clientify (upsert 60d; diario 05:15 UTC)
|
||||||
|
(script: `python3 scripts/dokploy_ensure_clientify_sync_cron.py`)
|
||||||
|
- `/api/cron/ghl-sync` — GoHighLevel upsert (diario 05:20 UTC)
|
||||||
|
- `/api/cron/shopify-sync` — pedidos paid Shopify → deals (diario 05:25 UTC)
|
||||||
|
- `/api/cron/health-alerts` — alertas sync atrasado / caída ecommerce (diario 07:00 UTC)
|
||||||
|
- `/api/cron/pipeline-publish` — piezas `aprobado` + `scheduled_at` vencida → Meta IG/FB
|
||||||
|
(cada ~10 min; script: `python3 scripts/dokploy_ensure_pipeline_publish_cron.py`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
|
"https://app.4ventures.es/api/cron/pipeline-publish"
|
||||||
|
```
|
||||||
|
|
||||||
`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar).
|
`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar).
|
||||||
|
|
||||||
|
|||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -3,6 +3,7 @@ const nextConfig = {
|
|||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
|
serverExternalPackages: ['xlsx'],
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
3239
package-lock.json
generated
Normal file
3239
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,15 +17,18 @@
|
|||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.0.2"
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"dotenv": "^18.0.5",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/plantillas/calendario-rrss-oficial.xlsx
Normal file
BIN
public/plantillas/calendario-rrss-oficial.xlsx
Normal file
Binary file not shown.
224
public/preview-seguimiento.html
Normal file
224
public/preview-seguimiento.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Preview · Seguimiento · Virginia Molet</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg:#0B0F19; --card:#111827; --side:#0D121F; --line:#1F2937; --brand:#E58A3C; --text:#F3F4F6; --muted:#9CA3AF; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background:var(--bg); color:var(--text); }
|
||||||
|
.shell { display:flex; min-height:100vh; }
|
||||||
|
aside { width:240px; background:var(--side); border-right:1px solid #1f2937; padding:20px 14px; }
|
||||||
|
.logo { display:flex; gap:10px; align-items:center; padding:8px; border-bottom:1px solid #1f293780; margin-bottom:18px; }
|
||||||
|
.mark { width:36px; height:36px; border-radius:10px; background:linear-gradient(135deg,#E58A3C,#B35E1B); display:grid; place-items:center; font-weight:800; }
|
||||||
|
nav a { display:flex; gap:10px; align-items:center; color:#D1D5DB; text-decoration:none; padding:10px 12px; border-radius:10px; font-size:14px; margin-bottom:4px; }
|
||||||
|
nav a.active { background:#1f293799; color:#fff; }
|
||||||
|
main { flex:1; padding:28px 36px; max-width:980px; }
|
||||||
|
h1 { margin:0; font-size:28px; letter-spacing:-.02em; }
|
||||||
|
.sub { color:var(--muted); font-size:14px; margin-top:6px; }
|
||||||
|
.badge { font-size:11px; color:#9CA3AF; border:1px solid #1f2937; background:#0d121f80; border-radius:8px; padding:8px 12px; }
|
||||||
|
.card { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:20px; margin-top:22px; }
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||||
|
.tabs { display:flex; gap:4px; background:#0003; border:1px solid #1f2937; border-radius:10px; padding:3px; }
|
||||||
|
.tabs span { font-size:12px; padding:6px 10px; border-radius:8px; color:#9CA3AF; }
|
||||||
|
.tabs span.on { background:#E58A3C33; color:#F0B27A; }
|
||||||
|
.chs { display:flex; gap:8px; margin:14px 0 8px; flex-wrap:wrap; }
|
||||||
|
.chs span { font-size:12px; border:1px solid #374151; border-radius:999px; padding:4px 10px; text-transform:capitalize; display:inline-flex; gap:6px; align-items:center; }
|
||||||
|
.chs i { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||||
|
.preview-note { font-size:11px; color:#FBBF24; margin-top:8px; }
|
||||||
|
ol { list-style:none; margin:18px 0 0; padding:0 0 0 14px; border-left:1px solid #1f2937; }
|
||||||
|
li { position:relative; margin:0 0 14px 12px; }
|
||||||
|
.dot { position:absolute; left:-19px; top:18px; width:12px; height:12px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 4px var(--bg); }
|
||||||
|
article { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:14px 16px; }
|
||||||
|
.row { display:flex; justify-content:space-between; gap:10px; }
|
||||||
|
.pill { font-size:10px; text-transform:uppercase; letter-spacing:.06em; border:1px solid; border-radius:6px; padding:2px 7px; margin-right:8px; }
|
||||||
|
.when { font-size:11px; color:#6B7280; }
|
||||||
|
h3 { margin:8px 0 0; font-size:14px; }
|
||||||
|
article p { margin:8px 0 0; font-size:12px; color:#9CA3AF; line-height:1.45; }
|
||||||
|
article a { color:#F0B27A; font-size:11px; text-decoration:none; white-space:nowrap; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside>
|
||||||
|
<div class="logo"><div class="mark">4V</div><div><strong style="font-size:13px">4VENTURES</strong><div style="font-size:11px;color:#9CA3AF">Centro de Mando</div></div></div>
|
||||||
|
<nav>
|
||||||
|
<a href="#">Métricas</a>
|
||||||
|
<a class="active" href="#">● Seguimiento</a>
|
||||||
|
<a href="#">Objetivos del Mes</a>
|
||||||
|
<a href="#">Calendario Editorial</a>
|
||||||
|
<a href="#">Biblioteca de Activos</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<h1>Seguimiento & evolución</h1>
|
||||||
|
<p class="sub">Virginia Molet · timeline Superhuman + series de canal</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge">Preview UI · datos reales de timeline</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="head">
|
||||||
|
<strong>Evolución por canal</strong>
|
||||||
|
<div class="tabs"><span class="on">Leads</span><span>Inversión</span><span>CPL</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="chs">
|
||||||
|
<span><i style="background:#E58A3C"></i>meta</span>
|
||||||
|
<span><i style="background:#38BDF8"></i>google</span>
|
||||||
|
<span><i style="background:#34D399"></i>organic</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 720 260" width="100%" style="max-width:100%">
|
||||||
|
<line x1="44" x2="704" y1="224" y2="224" stroke="#1F2937"/><text x="36" y="227" text-anchor="end" fill="#6B7280" font-size="10">0</text><line x1="44" x2="704" y1="172.0" y2="172.0" stroke="#1F2937"/><text x="36" y="175.0" text-anchor="end" fill="#6B7280" font-size="10">18</text><line x1="44" x2="704" y1="120.0" y2="120.0" stroke="#1F2937"/><text x="36" y="123.0" text-anchor="end" fill="#6B7280" font-size="10">36</text><line x1="44" x2="704" y1="68.0" y2="68.0" stroke="#1F2937"/><text x="36" y="71.0" text-anchor="end" fill="#6B7280" font-size="10">54</text><line x1="44" x2="704" y1="16" y2="16" stroke="#1F2937"/><text x="36" y="19" text-anchor="end" fill="#6B7280" font-size="10">72</text>
|
||||||
|
<path d="M 44.0 102.7 L 138.3 85.3 L 232.6 65.1 L 326.9 76.7 L 421.1 50.7 L 515.4 56.4 L 609.7 30.4 L 704.0 16.0" fill="none" stroke="#E58A3C" stroke-width="2.5"/><circle cx="44.0" cy="102.7" r="3.5" fill="#E58A3C"/><circle cx="138.3" cy="85.3" r="3.5" fill="#E58A3C"/><circle cx="232.6" cy="65.1" r="3.5" fill="#E58A3C"/><circle cx="326.9" cy="76.7" r="3.5" fill="#E58A3C"/><circle cx="421.1" cy="50.7" r="3.5" fill="#E58A3C"/><circle cx="515.4" cy="56.4" r="3.5" fill="#E58A3C"/><circle cx="609.7" cy="30.4" r="3.5" fill="#E58A3C"/><circle cx="704.0" cy="16.0" r="3.5" fill="#E58A3C"/><path d="M 44.0 189.3 L 138.3 183.6 L 232.6 192.2 L 326.9 177.8 L 421.1 172.0 L 515.4 180.7 L 609.7 166.2 L 704.0 160.4" fill="none" stroke="#38BDF8" stroke-width="2.5"/><circle cx="44.0" cy="189.3" r="3.5" fill="#38BDF8"/><circle cx="138.3" cy="183.6" r="3.5" fill="#38BDF8"/><circle cx="232.6" cy="192.2" r="3.5" fill="#38BDF8"/><circle cx="326.9" cy="177.8" r="3.5" fill="#38BDF8"/><circle cx="421.1" cy="172.0" r="3.5" fill="#38BDF8"/><circle cx="515.4" cy="180.7" r="3.5" fill="#38BDF8"/><circle cx="609.7" cy="166.2" r="3.5" fill="#38BDF8"/><circle cx="704.0" cy="160.4" r="3.5" fill="#38BDF8"/><path d="M 44.0 200.9 L 138.3 198.0 L 232.6 203.8 L 326.9 195.1 L 421.1 192.2 L 515.4 198.0 L 609.7 189.3 L 704.0 186.4" fill="none" stroke="#34D399" stroke-width="2.5"/><circle cx="44.0" cy="200.9" r="3.5" fill="#34D399"/><circle cx="138.3" cy="198.0" r="3.5" fill="#34D399"/><circle cx="232.6" cy="203.8" r="3.5" fill="#34D399"/><circle cx="326.9" cy="195.1" r="3.5" fill="#34D399"/><circle cx="421.1" cy="192.2" r="3.5" fill="#34D399"/><circle cx="515.4" cy="198.0" r="3.5" fill="#34D399"/><circle cx="609.7" cy="189.3" r="3.5" fill="#34D399"/><circle cx="704.0" cy="186.4" r="3.5" fill="#34D399"/>
|
||||||
|
<text x="44.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-06</text><text x="138.3" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-13</text><text x="232.6" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-20</text><text x="326.9" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-27</text><text x="421.1" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-03</text><text x="515.4" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-10</text><text x="609.7" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-17</text><text x="704.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-24</text>
|
||||||
|
</svg>
|
||||||
|
<p class="preview-note">La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style="margin-top:28px">
|
||||||
|
<strong style="font-size:15px">Timeline reunión a reunión <span style="color:#6B7280;font-weight:400">(12)</span></strong>
|
||||||
|
<ol>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">18 Aug 2026</span>
|
||||||
|
<h3>Gravitae: Meta Ads/Google ADs</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suvQLvnL" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>- **[Landing Page del Practitioner en Coaching Ejecutivo](https://gravitaeacademy.com/es/diploma-en-coaching-ejecutivo/)**
|
||||||
|
|
||||||
|
|
||||||
|
📊 KPIs a vigilar las primeras 2 semanas
|
||||||
|
|
||||||
|
## Copy of T</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">01 Jul 2026</span>
|
||||||
|
<h3>wbf_mad_sice.pdf</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suJVY_uk" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">01 Jul 2026</span>
|
||||||
|
<h3>Info emails Open Event 9/7/26</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_su8PImAd" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>> Añadimos a Carl Lewis que tiene mucho nombre
|
||||||
|
|
||||||
|
También quería compartir una oportunidad que puede ser de interés para quienes trabajáis en liderazgo, gestión de equipos o transfo</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">25 Jun 2026</span>
|
||||||
|
<h3>Email Open Event 9 julio.pdf</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suQBkgXM" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>Abrir en Superhuman para el detalle completo.</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">18 Jun 2026</span>
|
||||||
|
<h3>Email 1</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suSUWITB" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>1. EMAIL A TODOS LOS QUE SE HAN REGISTRADO AL EVENTO Y NO HAN IDO
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
TITULO: Ponte al día con lo que te perdiste en el I Salón Internacional de Coaching Ejecutivo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Hola NOMBRE,</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">08 Jun 2026</span>
|
||||||
|
<h3>WhatApp</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suguIU-Q" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>- Hola nombre. Quedan 4 días, te esperamos el viernes a las 9:30 en el saloninternacionacionalcoaching.com., por favor confirma tu asistencia abajo.
|
||||||
|
- Confirmar Asistencia
|
||||||
|
- N</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">24 Apr 2026</span>
|
||||||
|
<h3>Secuencia emails 240426</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sulZ9VOu" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>Es una [secuencia de 6 emails](https://drive.google.com/drive/folders/1i7717lRLgpgU5Y6FZonbbrpWjS3_pD96?usp=sharing), el primero para hoy si es posible y os ha dado tiempo a prepar</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<article>
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
|
||||||
|
<span class="when">23 Apr 2026</span>
|
||||||
|
<h3>Web Salón V2</h3>
|
||||||
|
</div>
|
||||||
|
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sumI6wo-" target="_blank">Superhuman ↗</a>
|
||||||
|
</div>
|
||||||
|
<p>**Pestanas: Registro / Agenda / Organiza / Contacto**
|
||||||
|
|
||||||
|
### **AGENDA**
|
||||||
|
|
||||||
|
Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
401
scripts/audit_all_brands.ts
Normal file
401
scripts/audit_all_brands.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* Full portfolio audit: Drive, brandbook, Meta/IG/FB/GAds, creatives folders.
|
||||||
|
* Optionally --fix to rematch Drive, enrich, ensure Ads/RRSS folders.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/audit_all_brands.ts
|
||||||
|
* npx tsx scripts/audit_all_brands.ts --fix
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
import {
|
||||||
|
applyMatches,
|
||||||
|
listClientFoldersUnderClientes,
|
||||||
|
matchFoldersToTenants,
|
||||||
|
scoreFolderToTenant,
|
||||||
|
} from '../src/lib/google-drive';
|
||||||
|
import { resolveBrandSlug } from '../src/lib/tenant-brand-aliases';
|
||||||
|
|
||||||
|
const FIX = process.argv.includes('--fix');
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Meta IG user ids / page ids are numeric; reject profile URLs. */
|
||||||
|
function isMetaNumericId(v: string | null | undefined): boolean {
|
||||||
|
return Boolean(v && /^\d{5,}$/.test(v.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ad account: act_123 or bare digits (normalize to act_ on fix). */
|
||||||
|
function isMetaAdAccountId(v: string | null | undefined): boolean {
|
||||||
|
if (!v) return false;
|
||||||
|
const s = v.trim();
|
||||||
|
return /^act_\d+$/i.test(s) || /^\d{5,}$/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAdAccountId(v: string): string {
|
||||||
|
const s = v.trim();
|
||||||
|
if (/^act_/i.test(s)) return s;
|
||||||
|
if (/^\d+$/.test(s)) return `act_${s}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tenant = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
agency_followed?: boolean;
|
||||||
|
drive_folder_id?: string | null;
|
||||||
|
meta_ad_account_id?: string | null;
|
||||||
|
meta_page_id?: string | null;
|
||||||
|
meta_ig_user_id?: string | null;
|
||||||
|
default_landing_url?: string | null;
|
||||||
|
creatives_enabled?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Integ = {
|
||||||
|
id?: string;
|
||||||
|
tenant_id: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
external_account_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Row = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
followed: boolean;
|
||||||
|
brandbook: boolean;
|
||||||
|
driveTenant: boolean;
|
||||||
|
driveInteg: boolean;
|
||||||
|
driveLabel: string;
|
||||||
|
metaAds: boolean;
|
||||||
|
facebook: boolean;
|
||||||
|
instagram: boolean;
|
||||||
|
googleAds: boolean;
|
||||||
|
ga4: boolean;
|
||||||
|
issues: string[];
|
||||||
|
scoreHint: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(`${url}/rest/v1/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...h, ...(init?.headers || {}) },
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 300)}`);
|
||||||
|
return text ? (JSON.parse(text) as T) : ([] as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tenants = await rest<Tenant[]>(
|
||||||
|
'tenants?select=id,slug,name,agency_followed,drive_folder_id,meta_ad_account_id,meta_page_id,meta_ig_user_id,default_landing_url,creatives_enabled&order=name.asc'
|
||||||
|
);
|
||||||
|
const active = tenants.filter((t) => t.agency_followed !== false);
|
||||||
|
const archived = tenants.filter((t) => t.agency_followed === false);
|
||||||
|
|
||||||
|
const integs = await rest<Integ[]>(
|
||||||
|
'integrations?select=id,tenant_id,provider,status,external_account_id,label'
|
||||||
|
);
|
||||||
|
const byTenant = new Map<string, Integ[]>();
|
||||||
|
for (const i of integs) {
|
||||||
|
const arr = byTenant.get(i.tenant_id) || [];
|
||||||
|
arr.push(i);
|
||||||
|
byTenant.set(i.tenant_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await rest<{ tenant_id: string; type: string }[]>(
|
||||||
|
'documents?type=eq.brandbook&select=tenant_id,type'
|
||||||
|
);
|
||||||
|
const hasBook = new Set(docs.map((d) => d.tenant_id));
|
||||||
|
|
||||||
|
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
|
||||||
|
console.log(
|
||||||
|
`Clientes root=${clientesRoot?.name || '?'} folders=${folders.length} active=${active.length} archived=${archived.length} fix=${FIX}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
for (const t of active) {
|
||||||
|
const list = byTenant.get(t.id) || [];
|
||||||
|
const connected = list.filter((i) => i.status === 'connected');
|
||||||
|
const find = (p: string) =>
|
||||||
|
connected.find((i) => i.provider === p) ||
|
||||||
|
list.find((i) => i.provider === p);
|
||||||
|
|
||||||
|
const drive = find('google_drive');
|
||||||
|
const meta = find('meta_ads');
|
||||||
|
const fb = find('facebook');
|
||||||
|
const ig = find('instagram');
|
||||||
|
const gads = find('google_ads');
|
||||||
|
const ga4 = find('ga4');
|
||||||
|
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!hasBook.has(t.id)) issues.push('sin brandbook');
|
||||||
|
if (!t.drive_folder_id && drive?.status !== 'connected') {
|
||||||
|
issues.push('sin Drive');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
drive?.status === 'connected' &&
|
||||||
|
/navidad|black\s*friday|campa/i.test(drive.label || '')
|
||||||
|
) {
|
||||||
|
issues.push(`Drive sospechoso: ${drive.label}`);
|
||||||
|
}
|
||||||
|
if (t.drive_folder_id && drive?.status === 'connected') {
|
||||||
|
if (drive.external_account_id !== t.drive_folder_id) {
|
||||||
|
issues.push('drive_folder_id ≠ integration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.drive_folder_id && drive?.status !== 'connected') {
|
||||||
|
issues.push('drive_folder_id sin integration connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync / validate tenant meta ids from integrations
|
||||||
|
if (!t.meta_ad_account_id && meta?.status === 'connected' && meta.external_account_id) {
|
||||||
|
issues.push('meta_ad_account_id vacío (hay integration)');
|
||||||
|
}
|
||||||
|
if (t.meta_ad_account_id && !isMetaAdAccountId(t.meta_ad_account_id)) {
|
||||||
|
issues.push(`meta_ad_account_id inválido: ${t.meta_ad_account_id}`);
|
||||||
|
} else if (
|
||||||
|
t.meta_ad_account_id &&
|
||||||
|
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
|
||||||
|
t.meta_page_id === t.meta_ad_account_id
|
||||||
|
) {
|
||||||
|
issues.push('meta_ad_account_id = meta_page_id (falta act_)');
|
||||||
|
}
|
||||||
|
if (!t.meta_page_id && fb?.status === 'connected' && fb.external_account_id) {
|
||||||
|
issues.push('meta_page_id vacío');
|
||||||
|
}
|
||||||
|
if (t.meta_page_id && !isMetaNumericId(t.meta_page_id)) {
|
||||||
|
issues.push(`meta_page_id inválido: ${t.meta_page_id}`);
|
||||||
|
}
|
||||||
|
if (!t.meta_ig_user_id && ig?.status === 'connected' && ig.external_account_id) {
|
||||||
|
issues.push('meta_ig_user_id vacío');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
t.meta_ig_user_id &&
|
||||||
|
!isMetaNumericId(t.meta_ig_user_id)
|
||||||
|
) {
|
||||||
|
issues.push(`meta_ig_user_id no numérico: ${t.meta_ig_user_id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ig?.status === 'connected' &&
|
||||||
|
ig.external_account_id &&
|
||||||
|
!isMetaNumericId(ig.external_account_id)
|
||||||
|
) {
|
||||||
|
issues.push(`IG integration no numérica: ${ig.external_account_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best Drive folder candidate if missing
|
||||||
|
let scoreHint = 0;
|
||||||
|
if (!t.drive_folder_id || drive?.status !== 'connected') {
|
||||||
|
const scored = folders
|
||||||
|
.map((f) => ({
|
||||||
|
f,
|
||||||
|
score: scoreFolderToTenant(f.name, t.name, t.slug),
|
||||||
|
}))
|
||||||
|
.filter((x) => x.score >= 70)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
scoreHint = scored[0]?.score || 0;
|
||||||
|
if (scored[0]) {
|
||||||
|
issues.push(`Drive candidate: ${scored[0].f.name} (${scored[0].score})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
slug: t.slug,
|
||||||
|
name: t.name,
|
||||||
|
followed: true,
|
||||||
|
brandbook: hasBook.has(t.id),
|
||||||
|
driveTenant: Boolean(t.drive_folder_id),
|
||||||
|
driveInteg: drive?.status === 'connected',
|
||||||
|
driveLabel: drive?.label || '',
|
||||||
|
metaAds: meta?.status === 'connected',
|
||||||
|
facebook: fb?.status === 'connected',
|
||||||
|
instagram: ig?.status === 'connected',
|
||||||
|
googleAds: gads?.status === 'connected',
|
||||||
|
ga4: ga4?.status === 'connected',
|
||||||
|
issues,
|
||||||
|
scoreHint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print table
|
||||||
|
console.log('\n=== ACTIVE PORTFOLIO ===');
|
||||||
|
for (const r of rows) {
|
||||||
|
const flags = [
|
||||||
|
r.brandbook ? 'BB' : '--',
|
||||||
|
r.driveInteg ? 'DRV' : '--',
|
||||||
|
r.metaAds ? 'META' : '----',
|
||||||
|
r.instagram ? 'IG' : '--',
|
||||||
|
r.facebook ? 'FB' : '--',
|
||||||
|
r.googleAds ? 'GADS' : '----',
|
||||||
|
r.ga4 ? 'GA4' : '---',
|
||||||
|
].join(' ');
|
||||||
|
const issueStr = r.issues.length ? ` ⚠ ${r.issues.join('; ')}` : '';
|
||||||
|
console.log(
|
||||||
|
`${r.slug.padEnd(22)} ${flags} ${r.driveLabel.slice(0, 36).padEnd(36)}${issueStr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== ARCHIVED ===');
|
||||||
|
for (const t of archived) {
|
||||||
|
console.log(` ${t.slug} · ${t.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FIX) {
|
||||||
|
console.log('\n=== FIXING ===');
|
||||||
|
// Rematch Drive for those missing / suspicious
|
||||||
|
const needDrive = active.filter((t) => {
|
||||||
|
const list = byTenant.get(t.id) || [];
|
||||||
|
const drive = list.find((i) => i.provider === 'google_drive');
|
||||||
|
if (drive?.status === 'connected' && /navidad/i.test(drive.label || '')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !t.drive_folder_id || drive?.status !== 'connected';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needDrive.length && folders.length) {
|
||||||
|
const matches = await matchFoldersToTenants(
|
||||||
|
needDrive.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
|
||||||
|
folders
|
||||||
|
);
|
||||||
|
const good = matches.filter(
|
||||||
|
(m) =>
|
||||||
|
m.status === 'matched' &&
|
||||||
|
m.folderId &&
|
||||||
|
!/navidad/i.test(m.folderName || '')
|
||||||
|
);
|
||||||
|
if (good.length) {
|
||||||
|
await applyMatches(good);
|
||||||
|
for (const m of good) {
|
||||||
|
await rest(`tenants?id=eq.${m.tenantId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({ drive_folder_id: m.folderId }),
|
||||||
|
});
|
||||||
|
console.log(`[drive] ${m.tenantSlug} ← ${m.folderName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[drive] no new high-confidence matches');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync meta_* columns from connected integrations (numeric IDs only)
|
||||||
|
for (const t of active) {
|
||||||
|
const list = byTenant.get(t.id) || [];
|
||||||
|
const connected = list.filter((i) => i.status === 'connected');
|
||||||
|
const patch: Record<string, string> = {};
|
||||||
|
const meta = connected.find((i) => i.provider === 'meta_ads');
|
||||||
|
const fb = connected.find((i) => i.provider === 'facebook');
|
||||||
|
const ig = connected.find((i) => i.provider === 'instagram');
|
||||||
|
if (meta?.external_account_id && isMetaAdAccountId(meta.external_account_id)) {
|
||||||
|
const ad = normalizeAdAccountId(meta.external_account_id);
|
||||||
|
if (t.meta_ad_account_id !== ad) patch.meta_ad_account_id = ad;
|
||||||
|
if (meta.id && meta.external_account_id !== ad) {
|
||||||
|
await rest(`integrations?id=eq.${meta.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({ external_account_id: ad }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!t.meta_ad_account_id && meta?.external_account_id) {
|
||||||
|
console.warn(`[ids] skip bad meta_ads for ${t.slug}: ${meta.external_account_id}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!t.meta_page_id || !isMetaNumericId(t.meta_page_id)) &&
|
||||||
|
fb?.external_account_id &&
|
||||||
|
isMetaNumericId(fb.external_account_id)
|
||||||
|
) {
|
||||||
|
patch.meta_page_id = fb.external_account_id.trim();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!t.meta_ig_user_id || !isMetaNumericId(t.meta_ig_user_id)) &&
|
||||||
|
ig?.external_account_id &&
|
||||||
|
isMetaNumericId(ig.external_account_id)
|
||||||
|
) {
|
||||||
|
patch.meta_ig_user_id = ig.external_account_id.trim();
|
||||||
|
} else if (
|
||||||
|
ig?.external_account_id &&
|
||||||
|
!isMetaNumericId(ig.external_account_id)
|
||||||
|
) {
|
||||||
|
console.warn(`[ids] skip bad IG for ${t.slug}: ${ig.external_account_id}`);
|
||||||
|
}
|
||||||
|
// Normalize bare digit ad account already on tenant
|
||||||
|
if (
|
||||||
|
t.meta_ad_account_id &&
|
||||||
|
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
|
||||||
|
!patch.meta_ad_account_id
|
||||||
|
) {
|
||||||
|
patch.meta_ad_account_id = normalizeAdAccountId(t.meta_ad_account_id);
|
||||||
|
}
|
||||||
|
const drive = connected.find((i) => i.provider === 'google_drive');
|
||||||
|
if (
|
||||||
|
drive?.external_account_id &&
|
||||||
|
!/navidad/i.test(drive.label || '') &&
|
||||||
|
t.drive_folder_id !== drive.external_account_id
|
||||||
|
) {
|
||||||
|
patch.drive_folder_id = drive.external_account_id;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
await rest(`tenants?id=eq.${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
console.log(`[ids] ${t.slug}`, patch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich + creatives folders for all active
|
||||||
|
for (const t of active) {
|
||||||
|
const r = await enrichTenantBrandbook({
|
||||||
|
tenantId: t.id,
|
||||||
|
forceSeed: !hasBook.has(t.id),
|
||||||
|
});
|
||||||
|
const drive = await ensureCreativesDriveFolders(
|
||||||
|
t.id,
|
||||||
|
resolveBrandSlug(t.slug) || t.slug
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${t.slug} bb=${r.ok} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning || 'skip'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary JSON for canvas
|
||||||
|
const summary = {
|
||||||
|
active: active.length,
|
||||||
|
archived: archived.length,
|
||||||
|
withBrandbook: rows.filter((r) => r.brandbook).length,
|
||||||
|
withDrive: rows.filter((r) => r.driveInteg).length,
|
||||||
|
withMeta: rows.filter((r) => r.metaAds).length,
|
||||||
|
withIg: rows.filter((r) => r.instagram).length,
|
||||||
|
withIssues: rows.filter((r) => r.issues.length).length,
|
||||||
|
rows,
|
||||||
|
archivedSlugs: archived.map((t) => t.slug),
|
||||||
|
};
|
||||||
|
const outPath = resolve(process.cwd(), 'scripts/.audit_portfolio.json');
|
||||||
|
await import('fs/promises').then((fs) =>
|
||||||
|
fs.writeFile(outPath, JSON.stringify(summary, null, 2), 'utf8')
|
||||||
|
);
|
||||||
|
console.log(`\nWrote ${outPath}`);
|
||||||
|
console.log(
|
||||||
|
`Summary: ${summary.withBrandbook}/${summary.active} BB · ${summary.withDrive}/${summary.active} Drive · ${summary.withMeta} Meta · ${summary.withIssues} with issues`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
82
scripts/audit_four_brands.ts
Normal file
82
scripts/audit_four_brands.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Re-enrich TYD/TSP/ECP/DTATF + dump Drive folder scores.
|
||||||
|
* npx tsx scripts/audit_four_brands.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
import { listClientFoldersUnderClientes } from '../src/lib/google-drive';
|
||||||
|
import { scoreNameToTenant } from '../src/lib/match';
|
||||||
|
|
||||||
|
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = { apikey: key, Authorization: `Bearer ${key}` };
|
||||||
|
|
||||||
|
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
|
||||||
|
console.log(
|
||||||
|
'Drive root',
|
||||||
|
clientesRoot?.name || '(none)',
|
||||||
|
'folders',
|
||||||
|
folders.length
|
||||||
|
);
|
||||||
|
for (const f of [...folders].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
console.log(' ', f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of FOCUS) {
|
||||||
|
const tr = await fetch(
|
||||||
|
`${url}/rest/v1/tenants?slug=eq.${slug}&select=id,name,slug,drive_folder_id`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const t = (await tr.json())[0] as
|
||||||
|
| { id: string; name: string; slug: string; drive_folder_id?: string }
|
||||||
|
| undefined;
|
||||||
|
if (!t) {
|
||||||
|
console.log(`[missing] ${slug}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored = folders
|
||||||
|
.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
id: f.id,
|
||||||
|
score: scoreNameToTenant(f.name, t.name, t.slug),
|
||||||
|
}))
|
||||||
|
.filter((x) => x.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 8);
|
||||||
|
console.log(`\n[${slug}] folder scores`, scored);
|
||||||
|
|
||||||
|
const r = await enrichTenantBrandbook({
|
||||||
|
tenantId: t.id,
|
||||||
|
forceSeed: true,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${slug} ok=${r.ok} created=${r.created} score=${r.completenessAfter} warn=${r.warning || '-'} sources=${r.sources.join('+')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const drive = await ensureCreativesDriveFolders(t.id, slug);
|
||||||
|
console.log(
|
||||||
|
`[creatives-folders] ${slug}`,
|
||||||
|
drive.ok ? 'ok' : drive.warning
|
||||||
|
);
|
||||||
|
|
||||||
|
const bb = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const rows = (await bb.json()) as { id: string }[];
|
||||||
|
console.log(`[bb] ${slug}`, rows[0]?.id || 'MISSING');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
61
scripts/backfill_pipeline_drive.ts
Normal file
61
scripts/backfill_pipeline_drive.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Backfill pipeline covers/slides → client Drive folder tree.
|
||||||
|
* npx tsx scripts/backfill_pipeline_drive.ts [tenantSlug]
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||||
|
import { backfillPipelineItemToDrive } from '../src/lib/pipeline-drive';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slugFilter = process.argv[2]?.trim();
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) throw new Error('service role missing');
|
||||||
|
|
||||||
|
const tRes = await fetch(`${base}/rest/v1/tenants?select=id,name,slug`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
let tenants = (await tRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}[];
|
||||||
|
if (slugFilter) {
|
||||||
|
tenants = tenants.filter(
|
||||||
|
(t) => t.slug === slugFilter || t.name.toLowerCase().includes(slugFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tenants.length) throw new Error('no tenants');
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
const pRes = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
t.id
|
||||||
|
)}&cover_path=not.is.null&select=id,title,format,caption,notes,scheduled_at,created_at,cover_path,slide_paths&order=created_at.desc&limit=50`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const pieces = pRes.ok ? await pRes.json() : [];
|
||||||
|
console.log(`\n=== ${t.name} (${pieces.length} con portada) ===`);
|
||||||
|
for (const item of pieces) {
|
||||||
|
const r = await backfillPipelineItemToDrive({
|
||||||
|
tenantId: t.id,
|
||||||
|
clientLabel: t.slug || t.name,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
item.title,
|
||||||
|
r.ok ? 'OK' : 'PARTIAL',
|
||||||
|
r.results.map((x) => `${x.slot}:${x.detail}`).join(' | ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
scripts/blue-parrot-pipeline-seed.json
Normal file
38
scripts/blue-parrot-pipeline-seed.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
{ "id": "oct-01", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "3 false friends que te delatan" },
|
||||||
|
{ "id": "oct-02", "format": "story", "month": "2026-10", "category": "aprende inglés", "title": "Palabra del día: deadline" },
|
||||||
|
{ "id": "oct-03", "format": "reel", "month": "2026-10", "category": "speaking", "title": "Preséntate en inglés en 30 segundos" },
|
||||||
|
{ "id": "oct-04", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Cursos intensivos: certifica tu nivel" },
|
||||||
|
{ "id": "oct-05", "format": "story", "month": "2026-10", "category": "test de nivel", "title": "¿No sabes por dónde empezar?" },
|
||||||
|
{ "id": "oct-06", "format": "carrusel", "month": "2026-10", "category": "cursos y certificaciones", "title": "Aptis, Cambridge o IELTS: ¿cuál te conviene?" },
|
||||||
|
{ "id": "oct-07", "format": "reel", "month": "2026-10", "category": "aprende inglés", "title": "Make o do: el truco para no fallar" },
|
||||||
|
{ "id": "oct-08", "format": "story", "month": "2026-10", "category": "comunidad", "title": "¿Qué idioma quieres aprender?" },
|
||||||
|
{ "id": "oct-09", "format": "carrusel", "month": "2026-10", "category": "inglés para el trabajo", "title": "3 frases para sonar más natural en una reunión" },
|
||||||
|
{ "id": "oct-10", "format": "reel", "month": "2026-10", "category": "comunidad", "title": "Un día en la academia" },
|
||||||
|
{ "id": "oct-11", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Grupos de máximo 8 alumnos" },
|
||||||
|
{ "id": "oct-12", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "Phrasal verbs con «get» que usarás cada día" },
|
||||||
|
|
||||||
|
{ "id": "nov-01", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "Friendsgiving: el inglés de Acción de Gracias con Friends" },
|
||||||
|
{ "id": "nov-02", "format": "reel", "month": "2026-11", "category": "inglés para el día a día", "title": "Black Friday: 5 palabras que verás en todas las tiendas" },
|
||||||
|
{ "id": "nov-03", "format": "story", "month": "2026-11", "category": "comunidad", "title": "¿Deal o bargain? Tú decides" },
|
||||||
|
{ "id": "nov-04", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "El inglés de Stranger Things" },
|
||||||
|
{ "id": "nov-05", "format": "post", "month": "2026-11", "category": "cursos y certificaciones", "title": "Certifica tu nivel antes de que acabe el año" },
|
||||||
|
{ "id": "nov-06", "format": "reel", "month": "2026-11", "category": "speaking", "title": "Thanks, cheers, much appreciated: cómo dar las gracias en inglés" },
|
||||||
|
{ "id": "nov-07", "format": "story", "month": "2026-11", "category": "aprende inglés", "title": "Palabra del día: leftovers" },
|
||||||
|
|
||||||
|
{ "id": "dic-01", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "El inglés de Solo en casa" },
|
||||||
|
{ "id": "dic-02", "format": "story", "month": "2026-12", "category": "aprende inglés", "title": "Palabra del día: cosy" },
|
||||||
|
{ "id": "dic-03", "format": "reel", "month": "2026-12", "category": "speaking", "title": "Felicita la Navidad en inglés sin decir solo Merry Christmas" },
|
||||||
|
{ "id": "dic-04", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "Love Actually: frases para practicar tu inglés" },
|
||||||
|
{ "id": "dic-05", "format": "post", "month": "2026-12", "category": "cursos y certificaciones", "title": "Estas Navidades, regala un idioma" },
|
||||||
|
{ "id": "dic-06", "format": "reel", "month": "2026-12", "category": "aprende inglés", "title": "Christmas vocabulary: mistletoe, stocking, Boxing Day" },
|
||||||
|
{ "id": "dic-07", "format": "story", "month": "2026-12", "category": "comunidad", "title": "¿Tu propósito de 2027 es aprender un idioma?" },
|
||||||
|
|
||||||
|
{ "id": "ene27-01", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "New Year's resolutions: di tus propósitos en inglés" },
|
||||||
|
{ "id": "ene27-02", "format": "story", "month": "2027-01", "category": "test de nivel", "title": "Empieza el año sabiendo tu nivel" },
|
||||||
|
{ "id": "ene27-03", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Take up, give up, keep up: 3 phrasal verbs para tus propósitos" },
|
||||||
|
{ "id": "ene27-04", "format": "post", "month": "2027-01", "category": "cursos y certificaciones", "title": "Nuevo año, nuevo idioma" },
|
||||||
|
{ "id": "ene27-05", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "Blue Monday: vocabulario para los días grises" },
|
||||||
|
{ "id": "ene27-06", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Winter vocabulary: freezing, chilly, to bundle up" },
|
||||||
|
{ "id": "ene27-07", "format": "story", "month": "2027-01", "category": "aprende inglés", "title": "Palabra del día: chilly" }
|
||||||
|
]
|
||||||
1711
scripts/complete_brandbooks.py
Normal file
1711
scripts/complete_brandbooks.py
Normal file
File diff suppressed because it is too large
Load Diff
930
scripts/data/scraped_brand_signals.json
Normal file
930
scripts/data/scraped_brand_signals.json
Normal file
@@ -0,0 +1,930 @@
|
|||||||
|
{
|
||||||
|
"4ventures-hub": {
|
||||||
|
"site": "https://4ventures.es/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#e05200",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#25d366",
|
||||||
|
4
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"DM Sans",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Cormorant Garamond",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Georgia",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"agua-miia": {
|
||||||
|
"site": "https://aguamiia.es/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#0085f2",
|
||||||
|
"#2071cf",
|
||||||
|
"#e8f1fb",
|
||||||
|
"#0f4f9e",
|
||||||
|
"#2e9b76",
|
||||||
|
"#e6f4ee",
|
||||||
|
"#1f7257",
|
||||||
|
"#2071cf",
|
||||||
|
"#e8f1fb",
|
||||||
|
"#0f4f9e",
|
||||||
|
"#c2603f",
|
||||||
|
"#f7ece6",
|
||||||
|
"#9a4527",
|
||||||
|
"#2FA36B",
|
||||||
|
"#2F80ED"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#2071cf",
|
||||||
|
34
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#16204a",
|
||||||
|
27
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#c2603f",
|
||||||
|
26
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#8f8f8f",
|
||||||
|
21
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#5b54c9",
|
||||||
|
19
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#dde6ed",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#e8f1fb",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f7ece6",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#27374d",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#e6f4ee",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#eef0f6",
|
||||||
|
5
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Urbanist",
|
||||||
|
22
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Nunito",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var( --e-global-typography-primary-font-family )",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"sans-serif !important",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var( --e-global-typography-accent-font-family )",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bps": {
|
||||||
|
"site": "https://blueparrotschool.com/",
|
||||||
|
"theme_color": "#0051AA",
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#0051aa",
|
||||||
|
13
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#5b6472",
|
||||||
|
10
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#201e22",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1187de",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#dce8f7",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#fdc526",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#d6e2f0",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#c62828",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0059c2",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#eef4fb",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ffd65c",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#dde5ee",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"var(--font-display)",
|
||||||
|
19
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Figtree",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Jost",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var(--font-body)",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"brava-dermoestetica": {
|
||||||
|
"site": "https://www.bravadermoestetica.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#7a3f4d",
|
||||||
|
69
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#61323d",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1c1917",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#25d366",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#fafaf9",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f8f5f2",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Myriad Pro",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Inter",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Playfair Display",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delgado-abogados": {
|
||||||
|
"site": "https://abogadosdelgado.es/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#7a00df",
|
||||||
|
"#007cba",
|
||||||
|
"#006ba1",
|
||||||
|
"#005a87",
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#212121",
|
||||||
|
20
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1863dc",
|
||||||
|
12
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f4f4f4",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#fffffe",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ebebeb",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#444444",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ff9900",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#25d366",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f0f0f0",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1ea0c3",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0757fe",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"var(--wp--preset--font-family--inter)",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var(--wp--preset--font-family--funnel-display)",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var(--wp--preset--font-family--allura) !important",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var(--wp--preset--font-family--archivo) !important",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"var(--wp--preset--font-family--barlow) !important",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dtatf": {
|
||||||
|
"site": "https://detuatuformacion.es/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#788793",
|
||||||
|
12
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#253645",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f6a800",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#273645",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ffd671",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f2a74b",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"var( --e-global-typography-text-font-family )",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Poppins-DeTUaTU",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ecp": {
|
||||||
|
"site": "https://escueladecuidadoresprofesionales.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0",
|
||||||
|
"#25d366",
|
||||||
|
"#303030"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#f7a801",
|
||||||
|
27
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#212121",
|
||||||
|
25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1863dc",
|
||||||
|
22
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f7a800",
|
||||||
|
20
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#dcaa18",
|
||||||
|
15
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#2ea3f2",
|
||||||
|
14
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#faa95e",
|
||||||
|
10
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f4f4f4",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#555555",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0056a7",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0b1f40",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ebebeb",
|
||||||
|
5
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Raleway",
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Open Sans",
|
||||||
|
12
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"equinsa-parking": {
|
||||||
|
"site": "https://equinsaparking.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#fafafa",
|
||||||
|
"#fbfbfb",
|
||||||
|
"#e7e7e7",
|
||||||
|
"#000",
|
||||||
|
"#046bd2",
|
||||||
|
"#045cb4",
|
||||||
|
"#1e293b",
|
||||||
|
"#334155",
|
||||||
|
"#F0F5FA",
|
||||||
|
"#FFFFFF",
|
||||||
|
"#D1D5DB",
|
||||||
|
"#111111",
|
||||||
|
"#111111",
|
||||||
|
"#046bd2",
|
||||||
|
"#045cb4"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#000028",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#046bd2",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#fbfbfb",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#475569",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#111827",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#9ca3af",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#045cb4",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1e293b",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#334155",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f0f5fa",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#d1d5db",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Roboto",
|
||||||
|
9
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fbm-abogados": {
|
||||||
|
"site": "https://fbmasociados.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#04243c",
|
||||||
|
75
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
16
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f9fbfb",
|
||||||
|
9
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"tahoma",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"halal-safety": {
|
||||||
|
"site": "https://www.halalsafety.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#efefef",
|
||||||
|
3
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"var(--font-display)",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Manrope",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"la-freseria": {
|
||||||
|
"site": "https://lafreseria.es/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#046bd2",
|
||||||
|
"#045cb4",
|
||||||
|
"#1e293b",
|
||||||
|
"#334155",
|
||||||
|
"#F0F5FA",
|
||||||
|
"#FFFFFF",
|
||||||
|
"#D1D5DB",
|
||||||
|
"#111111",
|
||||||
|
"#111111",
|
||||||
|
"#046bd2",
|
||||||
|
"#045cb4",
|
||||||
|
"#1e293b",
|
||||||
|
"#334155",
|
||||||
|
"#F0F5FA",
|
||||||
|
"#FFFFFF"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#475569",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#046bd2",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#9ca3af",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#045cb4",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1e293b",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#334155",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f0f5fa",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#d1d5db",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ef2b2d",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"La Fresería",
|
||||||
|
9
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Campaña La Fresería",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Campaña La Fresería 4",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Campaña La Fresería 3",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Campaña La Fresería 2",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"noberplast": {
|
||||||
|
"site": "https://noberplast.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#dde0e4",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#bbbbbb",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#06a161",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#db062c",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0072ab",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#e2e2e2",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#444444",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": []
|
||||||
|
},
|
||||||
|
"tsp": {
|
||||||
|
"site": "https://talentoscholarprogram.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#a4caef",
|
||||||
|
10
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#161b2f",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#a40406",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#2c3349",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Poppins",
|
||||||
|
20
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Fraunces",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Mulish",
|
||||||
|
3
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tyd": {
|
||||||
|
"site": "https://talentoydeporte.com/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#e92428",
|
||||||
|
18
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#004885",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#32373c",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f00101",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"Montserrat",
|
||||||
|
3
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Oswald",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"virginia-molet": {
|
||||||
|
"site": "https://gravitaeacademy.com/es/inicio/",
|
||||||
|
"theme_color": null,
|
||||||
|
"css_vars": [
|
||||||
|
"#000000",
|
||||||
|
"#abb8c3",
|
||||||
|
"#ffffff",
|
||||||
|
"#f78da7",
|
||||||
|
"#cf2e2e",
|
||||||
|
"#ff6900",
|
||||||
|
"#fcb900",
|
||||||
|
"#7bdcb5",
|
||||||
|
"#00d084",
|
||||||
|
"#8ed1fc",
|
||||||
|
"#0693e3",
|
||||||
|
"#9b51e0",
|
||||||
|
"#000000",
|
||||||
|
"#141214"
|
||||||
|
],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#1863dc",
|
||||||
|
49
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#212121",
|
||||||
|
25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#fafbfa",
|
||||||
|
21
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#004271",
|
||||||
|
19
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#f4f4f4",
|
||||||
|
8
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#003388",
|
||||||
|
7
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#141214",
|
||||||
|
6
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#0056a7",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#ebebeb",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#4e4b66",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#1e1e1e",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"#d0d5d2",
|
||||||
|
3
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": [
|
||||||
|
[
|
||||||
|
"quincy-cf",
|
||||||
|
5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Inter",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Inter'!important",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"serif!important",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wine-advisors": {
|
||||||
|
"site": "https://www.wineadvisor.mx/",
|
||||||
|
"theme_color": "#fafafa",
|
||||||
|
"css_vars": [],
|
||||||
|
"top_hex": [
|
||||||
|
[
|
||||||
|
"#cc0100",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"fonts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
133
scripts/dokploy_apply_approval_status_unify.py
Normal file
133
scripts/dokploy_apply_approval_status_unify.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply approval_status_unify.sql + generation_requests_copy_v2.sql via Dokploy supabase-db."""
|
||||||
|
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")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
SQLS = [
|
||||||
|
ROOT / "sql" / "approval_status_unify.sql",
|
||||||
|
ROOT / "sql" / "generation_requests_copy_v2.sql",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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().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 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
all_ok = True
|
||||||
|
for idx, sql_path in enumerate(SQLS):
|
||||||
|
tag = f"m{idx}"
|
||||||
|
print(f"APPLY {sql_path.name}", flush=True)
|
||||||
|
b64 = base64.b64encode(sql_path.read_text(encoding="utf-8").encode()).decode()
|
||||||
|
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||||
|
run(f"rm -f /tmp/{tag}.b64 /tmp/{tag}.sql; echo READY_{tag}", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/{tag}.b64", 350)
|
||||||
|
run(
|
||||||
|
f"base64 -d /tmp/{tag}.b64 > /tmp/{tag}.sql && "
|
||||||
|
f"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/{tag}.sql; "
|
||||||
|
f"echo EXIT_{tag}:$?",
|
||||||
|
18000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify constraints / sample statuses
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -c \""
|
||||||
|
"select conname, pg_get_constraintdef(oid) "
|
||||||
|
"from pg_constraint "
|
||||||
|
"where conname in ("
|
||||||
|
"'creative_pipeline_status_check',"
|
||||||
|
"'creatives_status_check',"
|
||||||
|
"'generation_requests_reason_check'"
|
||||||
|
");\" ; echo VERIFY_DONE",
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -c \""
|
||||||
|
"select status, count(*) from public.creative_pipeline group by 1 order by 1;\" "
|
||||||
|
"; echo PIPE_STATUSES",
|
||||||
|
6000,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
for idx, sql_path in enumerate(SQLS):
|
||||||
|
tag = f"m{idx}"
|
||||||
|
ok = f"EXIT_{tag}:0" in text
|
||||||
|
print(f"SQL_OK {sql_path.name}", ok, flush=True)
|
||||||
|
all_ok = all_ok and ok
|
||||||
|
|
||||||
|
print("--- terminal tail ---", flush=True)
|
||||||
|
print(text[-2500:], flush=True)
|
||||||
|
return 0 if all_ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
105
scripts/dokploy_apply_change_history.py
Normal file
105
scripts/dokploy_apply_change_history.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/change_history.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" / "change_history.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
|
||||||
|
if not SQL.is_file():
|
||||||
|
print("NO_SQL", flush=True)
|
||||||
|
return 3
|
||||||
|
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/ch.b64 /tmp/ch.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/ch.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/ch.b64 > /tmp/ch.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ch.sql; echo EXIT:$?",
|
||||||
|
12000,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -c "
|
||||||
|
"\"SELECT table_name FROM information_schema.tables "
|
||||||
|
"WHERE table_schema='public' AND table_name='change_history';\" ; echo TABLE_DONE",
|
||||||
|
4000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text and "change_history" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
if not ok:
|
||||||
|
print(text[-2500:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
95
scripts/dokploy_apply_documents_rls.py
Normal file
95
scripts/dokploy_apply_documents_rls.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/documents_rls.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" / "documents_rls.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 1
|
||||||
|
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/cpl.b64 /tmp/cpl.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/cpl.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/cpl.b64 > /tmp/cpl.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cpl.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[-500:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
92
scripts/dokploy_apply_gohighlevel.py
Normal file
92
scripts/dokploy_apply_gohighlevel.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/gohighlevel.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" / "gohighlevel.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/ghl.b64 /tmp/ghl.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/ghl.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/ghl.b64 > /tmp/ghl.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ghl.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())
|
||||||
95
scripts/dokploy_apply_pipeline_calendar_link.py
Normal file
95
scripts/dokploy_apply_pipeline_calendar_link.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/creative_pipeline_calendar_link.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" / "creative_pipeline_calendar_link.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 1
|
||||||
|
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/cpl.b64 /tmp/cpl.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/cpl.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/cpl.b64 > /tmp/cpl.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cpl.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[-500:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
95
scripts/dokploy_apply_pipeline_schedule.py
Normal file
95
scripts/dokploy_apply_pipeline_schedule.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/creative_pipeline_schedule.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" / "creative_pipeline_schedule.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 1
|
||||||
|
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/cps.b64 /tmp/cps.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/cps.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/cps.b64 > /tmp/cps.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cps.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[-800:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
91
scripts/dokploy_apply_woocommerce_sql.py
Normal file
91
scripts/dokploy_apply_woocommerce_sql.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/woocommerce_provider.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" / "woocommerce_provider.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": 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)
|
||||||
|
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/woo.b64 /tmp/woo.sql; echo READY", 1500)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
run(f"printf '%s' '{ch}' >> /tmp/woo.b64", 200 if i % 5 else 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/woo.b64 > /tmp/woo.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/woo.sql && echo WOO_SQL_OK",
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
page.screenshot(path=str(ROOT / "scripts/dokploy-shots/woo_sql.png"))
|
||||||
|
browser.close()
|
||||||
|
print("done", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
133
scripts/dokploy_ensure_clientify_sync_cron.py
Normal file
133
scripts/dokploy_ensure_clientify_sync_cron.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ensure Dokploy schedule for daily Clientify CRM reconciliation."""
|
||||||
|
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")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||||
|
SCHEDULE_NAME = "clientify-sync"
|
||||||
|
# 05:15 UTC daily — before typical morning review in Spain (07:15)
|
||||||
|
CRON_EXPR = "15 5 * * *"
|
||||||
|
CMD = (
|
||||||
|
"bash -lc "
|
||||||
|
"'curl -sS -H \"Authorization: Bearer $CRON_SECRET\" "
|
||||||
|
"\"https://app.4ventures.es/api/cron/clientify-sync?lookback=60\"'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cred(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
for p in (BPS / ".env", ROOT / ".env.local"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = cred("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1500, "height": 1200})
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = page.evaluate(
|
||||||
|
"""async ({ appId, name, cron, command }) => {
|
||||||
|
const post = async (path, body) => {
|
||||||
|
const r = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch {}
|
||||||
|
return { ok: r.ok, status: r.status, text: text.slice(0, 1200), json };
|
||||||
|
};
|
||||||
|
const get = async (path) => {
|
||||||
|
const r = await fetch(path, { credentials: 'include' });
|
||||||
|
const text = await r.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch {}
|
||||||
|
return { ok: r.ok, status: r.status, text: text.slice(0, 2000), json };
|
||||||
|
};
|
||||||
|
|
||||||
|
const listed = await get(
|
||||||
|
'/api/trpc/schedule.list?batch=1&input=' +
|
||||||
|
encodeURIComponent(JSON.stringify({
|
||||||
|
'0': { json: { id: appId, scheduleType: 'application' } }
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const rows =
|
||||||
|
listed.json?.[0]?.result?.data?.json ||
|
||||||
|
listed.json?.[0]?.result?.data ||
|
||||||
|
[];
|
||||||
|
const existing = Array.isArray(rows)
|
||||||
|
? rows.filter((r) => (r?.name || '') === name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const row of existing) {
|
||||||
|
const sid = row.scheduleId || row.id;
|
||||||
|
if (!sid) continue;
|
||||||
|
await post('/api/trpc/schedule.delete?batch=1', {
|
||||||
|
'0': { json: { scheduleId: sid } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await post('/api/trpc/schedule.create?batch=1', {
|
||||||
|
'0': {
|
||||||
|
json: {
|
||||||
|
name,
|
||||||
|
cronExpression: cron,
|
||||||
|
shellType: 'bash',
|
||||||
|
command,
|
||||||
|
applicationId: appId,
|
||||||
|
scheduleType: 'application',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { deleted: existing.length, created, listedCount: Array.isArray(rows) ? rows.length : 0 };
|
||||||
|
}""",
|
||||||
|
{
|
||||||
|
"appId": APP_ID,
|
||||||
|
"name": SCHEDULE_NAME,
|
||||||
|
"cron": CRON_EXPR,
|
||||||
|
"command": CMD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(result, indent=2)[:4000], flush=True)
|
||||||
|
ok = bool(result.get("created", {}).get("ok"))
|
||||||
|
browser.close()
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
134
scripts/dokploy_ensure_pipeline_publish_cron.py
Normal file
134
scripts/dokploy_ensure_pipeline_publish_cron.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ensure / refresh Dokploy schedule for pipeline Meta publish (every 5 min)."""
|
||||||
|
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")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||||
|
SCHEDULE_NAME = "pipeline-publish"
|
||||||
|
CRON_EXPR = "*/5 * * * *"
|
||||||
|
# Runs inside the app container so $CRON_SECRET from Dokploy env is available.
|
||||||
|
CMD = (
|
||||||
|
"bash -lc "
|
||||||
|
"'curl -sS -H \"Authorization: Bearer $CRON_SECRET\" "
|
||||||
|
"\"https://app.4ventures.es/api/cron/pipeline-publish?limit=20\"'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cred(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
for p in (BPS / ".env", ROOT / ".env.local"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = cred("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1500, "height": 1200})
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = page.evaluate(
|
||||||
|
"""async ({ appId, name, cron, command }) => {
|
||||||
|
const post = async (path, body) => {
|
||||||
|
const r = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch {}
|
||||||
|
return { ok: r.ok, status: r.status, text: text.slice(0, 1200), json };
|
||||||
|
};
|
||||||
|
const get = async (path) => {
|
||||||
|
const r = await fetch(path, { credentials: 'include' });
|
||||||
|
const text = await r.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch {}
|
||||||
|
return { ok: r.ok, status: r.status, text: text.slice(0, 2000), json };
|
||||||
|
};
|
||||||
|
|
||||||
|
// List application schedules (Dokploy v0.30)
|
||||||
|
const listed = await get(
|
||||||
|
'/api/trpc/schedule.list?batch=1&input=' +
|
||||||
|
encodeURIComponent(JSON.stringify({
|
||||||
|
'0': { json: { id: appId, scheduleType: 'application' } }
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const rows =
|
||||||
|
listed.json?.[0]?.result?.data?.json ||
|
||||||
|
listed.json?.[0]?.result?.data ||
|
||||||
|
[];
|
||||||
|
const existing = Array.isArray(rows)
|
||||||
|
? rows.filter((r) => (r?.name || '') === name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const row of existing) {
|
||||||
|
const sid = row.scheduleId || row.id;
|
||||||
|
if (!sid) continue;
|
||||||
|
await post('/api/trpc/schedule.delete?batch=1', {
|
||||||
|
'0': { json: { scheduleId: sid } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await post('/api/trpc/schedule.create?batch=1', {
|
||||||
|
'0': {
|
||||||
|
json: {
|
||||||
|
name,
|
||||||
|
cronExpression: cron,
|
||||||
|
shellType: 'bash',
|
||||||
|
command,
|
||||||
|
applicationId: appId,
|
||||||
|
scheduleType: 'application',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { deleted: existing.length, created };
|
||||||
|
}""",
|
||||||
|
{
|
||||||
|
"appId": APP_ID,
|
||||||
|
"name": SCHEDULE_NAME,
|
||||||
|
"cron": CRON_EXPR,
|
||||||
|
"command": CMD,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(json.dumps(result, indent=2)[:4000], flush=True)
|
||||||
|
ok = bool(result.get("created", {}).get("ok"))
|
||||||
|
browser.close()
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
159
scripts/dokploy_set_cron_secret.py
Normal file
159
scripts/dokploy_set_cron_secret.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merge CRON_SECRET into client-portal Dokploy env, then Deploy."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||||
|
APP = (
|
||||||
|
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
|
||||||
|
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
|
||||||
|
)
|
||||||
|
HEALTH = "https://app.4ventures.es/api/health"
|
||||||
|
|
||||||
|
|
||||||
|
def cred(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
for p in (BPS / ".env", ROOT / ".env.local"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
cron = cred("CRON_SECRET")
|
||||||
|
if not cron:
|
||||||
|
print("missing CRON_SECRET locally", flush=True)
|
||||||
|
return 2
|
||||||
|
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = cred("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
|
||||||
|
page.goto(APP, wait_until="networkidle", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
result = page.evaluate(
|
||||||
|
"""async ({ appId, cronSecret }) => {
|
||||||
|
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
|
||||||
|
const one = await fetch(oneUrl, { credentials: 'include' });
|
||||||
|
const oneText = await one.text();
|
||||||
|
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
|
||||||
|
let envText = '';
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(oneText);
|
||||||
|
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
|
||||||
|
envText = data?.env || data?.environment || '';
|
||||||
|
if (typeof envText !== 'string') envText = '';
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
|
||||||
|
}
|
||||||
|
const lines = envText.split('\\n').filter(Boolean);
|
||||||
|
const map = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const i = line.indexOf('=');
|
||||||
|
if (i < 0) continue;
|
||||||
|
map[line.slice(0, i)] = line.slice(i + 1);
|
||||||
|
}
|
||||||
|
map['CRON_SECRET'] = cronSecret;
|
||||||
|
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
|
||||||
|
const keys = Object.keys(map);
|
||||||
|
|
||||||
|
const tries = [];
|
||||||
|
const payloads = [
|
||||||
|
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
|
||||||
|
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||||
|
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||||
|
];
|
||||||
|
for (const t of payloads) {
|
||||||
|
const r = await fetch(t.path, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(t.body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) });
|
||||||
|
if (r.ok) return { ok: true, keys, tries, hadCron: keys.includes('CRON_SECRET') };
|
||||||
|
}
|
||||||
|
return { ok: false, keys, tries, envLen: envText.length };
|
||||||
|
}""",
|
||||||
|
{"appId": APP_ID, "cronSecret": cron},
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"ok": result.get("ok"),
|
||||||
|
"hadCron": result.get("hadCron"),
|
||||||
|
"keyCount": len(result.get("keys") or []),
|
||||||
|
"tries": result.get("tries"),
|
||||||
|
"step": result.get("step"),
|
||||||
|
"status": result.get("status"),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)[:2000],
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if not result.get("ok"):
|
||||||
|
browser.close()
|
||||||
|
return 6
|
||||||
|
|
||||||
|
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(700)
|
||||||
|
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
|
||||||
|
if conf.count():
|
||||||
|
conf.first.click()
|
||||||
|
print("DEPLOY_CLICKED", flush=True)
|
||||||
|
|
||||||
|
for i in range(30):
|
||||||
|
time.sleep(10)
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
|
||||||
|
except Exception as e:
|
||||||
|
code = str(e)
|
||||||
|
print(f"health_try{i}", code, flush=True)
|
||||||
|
if code == 200 and i >= 6:
|
||||||
|
# give container time to pick new env after first healthy
|
||||||
|
break
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
121
scripts/dokploy_set_gads_login_cid.py
Normal file
121
scripts/dokploy_set_gads_login_cid.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Merge GOOGLE_ADS_LOGIN_CUSTOMER_ID into client-portal Dokploy env."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
|
||||||
|
APP = (
|
||||||
|
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
|
||||||
|
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
|
||||||
|
)
|
||||||
|
KEY = "GOOGLE_ADS_LOGIN_CUSTOMER_ID"
|
||||||
|
VALUE = "5963150101"
|
||||||
|
|
||||||
|
|
||||||
|
def cred(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
p = BPS / ".env"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = cred("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
chromium = Path.home() / "Library/Caches/ms-playwright/chromium-1148/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
|
||||||
|
launch_kwargs = {"headless": True}
|
||||||
|
if chromium.is_file():
|
||||||
|
launch_kwargs["executable_path"] = str(chromium)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(**launch_kwargs)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
|
||||||
|
page.goto(APP, wait_until="networkidle", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
result = page.evaluate(
|
||||||
|
"""async ({ appId, key, value }) => {
|
||||||
|
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
|
||||||
|
const one = await fetch(oneUrl, { credentials: 'include' });
|
||||||
|
const oneText = await one.text();
|
||||||
|
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
|
||||||
|
let envText = '';
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(oneText);
|
||||||
|
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
|
||||||
|
envText = data?.env || data?.environment || '';
|
||||||
|
if (typeof envText !== 'string') envText = '';
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
|
||||||
|
}
|
||||||
|
const lines = envText.split('\\n').filter(Boolean);
|
||||||
|
const map = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const i = line.indexOf('=');
|
||||||
|
if (i < 0) continue;
|
||||||
|
map[line.slice(0, i)] = line.slice(i + 1);
|
||||||
|
}
|
||||||
|
const prev = map[key] || null;
|
||||||
|
map[key] = value;
|
||||||
|
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
|
||||||
|
|
||||||
|
const tries = [];
|
||||||
|
const payloads = [
|
||||||
|
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
|
||||||
|
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||||
|
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
|
||||||
|
];
|
||||||
|
for (const t of payloads) {
|
||||||
|
const r = await fetch(t.path, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(t.body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
tries.push({ path: t.path, status: r.status, body: text.slice(0, 160) });
|
||||||
|
if (r.ok) {
|
||||||
|
return { ok: true, prev, set: value, keys: Object.keys(map).length, tries };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, step: 'update', prev, tries };
|
||||||
|
}""",
|
||||||
|
{"appId": APP_ID, "key": KEY, "value": VALUE},
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print(result, flush=True)
|
||||||
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
134
scripts/dokploy_verify_approval_status.py
Normal file
134
scripts/dokploy_verify_approval_status.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify + re-apply approval_status_unify.sql if needed via Dokploy supabase-db."""
|
||||||
|
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")
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
SQL = ROOT / "sql" / "approval_status_unify.sql"
|
||||||
|
|
||||||
|
|
||||||
|
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().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 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Disable pager
|
||||||
|
run("export PAGER=cat; export LESS=-FX; echo PAGER_OK", 1000)
|
||||||
|
|
||||||
|
# Re-apply idempotent migration
|
||||||
|
run("rm -f /tmp/asu.b64 /tmp/asu.sql; echo READY", 1000)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/asu.b64", 300)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/asu.b64 > /tmp/asu.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -P pager=off -f /tmp/asu.sql; "
|
||||||
|
"echo EXIT_ASU:$?",
|
||||||
|
20000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compact verifies
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -P pager=off -tAc "
|
||||||
|
"\"select string_agg(conname, ',' order by conname) from pg_constraint "
|
||||||
|
"where conname in ("
|
||||||
|
"'creative_pipeline_status_check',"
|
||||||
|
"'creatives_status_check',"
|
||||||
|
"'generation_requests_reason_check'"
|
||||||
|
");\" ; echo CONS:$?",
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -P pager=off -tAc "
|
||||||
|
"\"select string_agg(status||':'||c, ',' order by status) from ("
|
||||||
|
"select status, count(*)::text c from public.creative_pipeline group by 1) s;\" "
|
||||||
|
"; echo PIPE:$?",
|
||||||
|
6000,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -P pager=off -tAc "
|
||||||
|
"\"select string_agg(status||':'||c, ',' order by status) from ("
|
||||||
|
"select status, count(*)::text c from public.creatives group by 1) s;\" "
|
||||||
|
"; echo ADS:$?",
|
||||||
|
6000,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
ok = "EXIT_ASU:0" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
print("--- tail ---", flush=True)
|
||||||
|
print(text[-2000:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
52
scripts/enrich_brandbooks.ts
Normal file
52
scripts/enrich_brandbooks.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Auto-fill Zona de marca for all tenants (or one slug) + ensure Drive folders.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/enrich_brandbooks.ts
|
||||||
|
* npx tsx scripts/enrich_brandbooks.ts bps
|
||||||
|
* DRY_RUN=1 npx tsx scripts/enrich_brandbooks.ts
|
||||||
|
* FORCE_SEED=1 npx tsx scripts/enrich_brandbooks.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichAllTenantBrandbooks } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slugFilter = process.argv[2]?.trim();
|
||||||
|
const dryRun = process.env.DRY_RUN === '1';
|
||||||
|
const forceSeed = process.env.FORCE_SEED === '1';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Enrich brandbooks · dryRun=${dryRun} · forceSeed=${forceSeed} · filter=${slugFilter || '*'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await enrichAllTenantBrandbooks({
|
||||||
|
dryRun,
|
||||||
|
forceSeed,
|
||||||
|
slugFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(
|
||||||
|
`[${r.slug}] ${r.ok ? 'OK' : 'FAIL'} · ${r.created ? 'created' : 'updated'} · ` +
|
||||||
|
`score ${r.completenessBefore}→${r.completenessAfter} · ` +
|
||||||
|
`sources=${r.sources.join('+')} · filled=${r.filledFields.slice(0, 12).join(',')}` +
|
||||||
|
(r.warning ? ` · ${r.warning}` : '')
|
||||||
|
);
|
||||||
|
if (r.ok && !dryRun) {
|
||||||
|
const drive = await ensureCreativesDriveFolders(r.tenantId, r.slug);
|
||||||
|
console.log(
|
||||||
|
` drive folders: ${drive.ok ? 'ads+rrss ok' : drive.warning || 'skip'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Done · ${results.length} tenants`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
46
scripts/enrich_thin_dna.ts
Normal file
46
scripts/enrich_thin_dna.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Force-seed enrich for DNA-thin brandbooks after calendar-generate-brand updates.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
|
||||||
|
const THIN = [
|
||||||
|
'delgado-abogados',
|
||||||
|
'equinsa-parking',
|
||||||
|
'fbm-abogados',
|
||||||
|
'la-freseria',
|
||||||
|
'virginia-molet',
|
||||||
|
'wine-advisors',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
};
|
||||||
|
const tenants = (await fetch(
|
||||||
|
`${url}/rest/v1/tenants?slug=in.(${THIN.join(',')})&select=id,slug`,
|
||||||
|
{ headers: h }
|
||||||
|
).then((r) => r.json())) as { id: string; slug: string }[];
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
|
||||||
|
console.log(
|
||||||
|
t.slug,
|
||||||
|
`${r.completenessBefore}→${r.completenessAfter}`,
|
||||||
|
'filled',
|
||||||
|
(r.filledFields || []).slice(0, 10).join(',') || '-',
|
||||||
|
r.warning || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
132
scripts/ensure_ecp_tsp_drive.ts
Normal file
132
scripts/ensure_ecp_tsp_drive.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Create client Drive roots for ECP + TSP under 03-Clientes if missing,
|
||||||
|
* link integrations + tenants.drive_folder_id, ensure Ads/RRSS creatives folders,
|
||||||
|
* re-enrich brandbooks.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/ensure_ecp_tsp_drive.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
import {
|
||||||
|
ensureChildFolder,
|
||||||
|
findClientesRootFolder,
|
||||||
|
listClientFoldersUnderClientes,
|
||||||
|
} from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGETS: { slug: string; folderName: string }[] = [
|
||||||
|
{ slug: 'ecp', folderName: '50-ECP Escuela de Cuidadores' },
|
||||||
|
{ slug: 'tsp', folderName: '51-TSP Talento Scholar Program' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const listed = await listClientFoldersUnderClientes();
|
||||||
|
const root = listed.clientesRoot || (await findClientesRootFolder());
|
||||||
|
if (!root?.id) throw new Error('No se encontró carpeta Clientes en Drive');
|
||||||
|
console.log('Clientes root', root.name, root.id);
|
||||||
|
|
||||||
|
const folders = listed.folders;
|
||||||
|
|
||||||
|
for (const t of TARGETS) {
|
||||||
|
const tr = await fetch(
|
||||||
|
`${url}/rest/v1/tenants?slug=eq.${t.slug}&select=id,slug,name`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const tenant = (await tr.json())[0] as
|
||||||
|
| { id: string; slug: string; name: string }
|
||||||
|
| undefined;
|
||||||
|
if (!tenant) {
|
||||||
|
console.log(`[skip] ${t.slug} no existe`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let folder = folders.find((f) =>
|
||||||
|
t.slug === 'ecp'
|
||||||
|
? /ecp|cuidadores/i.test(f.name) && !/navidad/i.test(f.name)
|
||||||
|
: /tsp|scholar/i.test(f.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
const id = await ensureChildFolder(root.id, t.folderName);
|
||||||
|
folder = { id, name: t.folderName };
|
||||||
|
console.log(`[create] ${t.folderName} → ${id}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[exist] ${folder.name} → ${folder.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(
|
||||||
|
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: h,
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: folder.id,
|
||||||
|
label: folder.name,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upsert if no google_drive row
|
||||||
|
const check = await fetch(
|
||||||
|
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive&select=id`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const rows = (await check.json()) as { id: string }[];
|
||||||
|
if (!rows.length) {
|
||||||
|
await fetch(`${url}/rest/v1/integrations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: h,
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenant.id,
|
||||||
|
provider: 'google_drive',
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: folder.id,
|
||||||
|
label: folder.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`${url}/rest/v1/tenants?id=eq.${tenant.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: h,
|
||||||
|
body: JSON.stringify({ drive_folder_id: folder.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const creatives = await ensureCreativesDriveFolders(tenant.id, t.slug);
|
||||||
|
console.log(
|
||||||
|
`[creatives] ${t.slug}`,
|
||||||
|
creatives.ok ? 'Ads+RRSS ok' : creatives.warning
|
||||||
|
);
|
||||||
|
|
||||||
|
const enrich = await enrichTenantBrandbook({
|
||||||
|
tenantId: tenant.id,
|
||||||
|
forceSeed: true,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${t.slug} ok=${enrich.ok} score=${enrich.completenessAfter} warn=${enrich.warning || '-'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
63
scripts/export_bps_calendar_doc.ts
Normal file
63
scripts/export_bps_calendar_doc.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const IDS = [
|
||||||
|
'1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s',
|
||||||
|
'1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||||
|
for (const id of IDS) {
|
||||||
|
const metaR = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType&supportsAllDrives=true`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
const meta = (await metaR.json()) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
console.log('META', meta);
|
||||||
|
const mime = meta.mimeType || '';
|
||||||
|
let buf: Buffer;
|
||||||
|
if (mime.includes('spreadsheet')) {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/csv')}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
writeFileSync(`/tmp/bps-cal/${id}.csv`, buf);
|
||||||
|
} else if (mime.includes('document')) {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/plain')}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
writeFileSync(`/tmp/bps-cal/${id}.txt`, buf);
|
||||||
|
} else {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
writeFileSync(`/tmp/bps-cal/${id}.bin`, buf);
|
||||||
|
}
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
console.log('size', buf.length);
|
||||||
|
console.log(text.slice(0, 3000));
|
||||||
|
console.log('----\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
33
scripts/export_bps_xlsx.ts
Normal file
33
scripts/export_bps_xlsx.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
|
||||||
|
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
|
||||||
|
|
||||||
|
async function exportXlsx(token: string, id: string, path: string) {
|
||||||
|
const mime =
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent(mime)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(`${id} ${r.status} ${(await r.text()).slice(0, 300)}`);
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
writeFileSync(path, buf);
|
||||||
|
console.log('wrote', path, buf.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||||
|
await exportXlsx(token, ID, '/tmp/bps-cal/bps-calendario.xlsx');
|
||||||
|
await exportXlsx(token, OCT, '/tmp/bps-cal/bps-octubre.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
232
scripts/fix_brand_gaps.ts
Normal file
232
scripts/fix_brand_gaps.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Fix remaining gaps for TYD / TSP / ECP / DTATF:
|
||||||
|
* - Rematch Drive folders (esp. ECP, TSP)
|
||||||
|
* - Ensure brandbooks exist
|
||||||
|
* - Seed disconnected integration shells for new tenants
|
||||||
|
* - Ensure Creatividades-Ads/RRSS folders
|
||||||
|
*
|
||||||
|
* npx tsx scripts/fix_brand_gaps.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
import {
|
||||||
|
applyMatches,
|
||||||
|
listClientFoldersUnderClientes,
|
||||||
|
matchFoldersToTenants,
|
||||||
|
scoreFolderToTenant,
|
||||||
|
} from '../src/lib/google-drive';
|
||||||
|
import {
|
||||||
|
BRAND_DISPLAY_NAMES,
|
||||||
|
brandSiblingNote,
|
||||||
|
} from '../src/lib/tenant-brand-aliases';
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
'meta_ads',
|
||||||
|
'facebook',
|
||||||
|
'instagram',
|
||||||
|
'google_ads',
|
||||||
|
'google_drive',
|
||||||
|
'ga4',
|
||||||
|
'search_console',
|
||||||
|
'gohighlevel',
|
||||||
|
'clientify',
|
||||||
|
'linkedin',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(`${url}/rest/v1/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...h, ...(init?.headers || {}) },
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
|
||||||
|
return text ? (JSON.parse(text) as T) : ([] as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureIntegrationShells(tenantId: string) {
|
||||||
|
const existing = await rest<{ provider: string }[]>(
|
||||||
|
`integrations?tenant_id=eq.${tenantId}&select=provider`
|
||||||
|
);
|
||||||
|
const have = new Set(existing.map((r) => r.provider));
|
||||||
|
for (const provider of PROVIDERS) {
|
||||||
|
if (have.has(provider)) continue;
|
||||||
|
await rest('integrations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Prefer: 'resolution=ignore-duplicates,return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
provider,
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: false,
|
||||||
|
label: provider,
|
||||||
|
}),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tenants = await rest<
|
||||||
|
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
|
||||||
|
>(
|
||||||
|
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
|
||||||
|
);
|
||||||
|
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
|
||||||
|
console.log(
|
||||||
|
'focus tenants',
|
||||||
|
tenants.map((t) => t.slug).join(', ')
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
await ensureIntegrationShells(t.id);
|
||||||
|
console.log(`[shells] ${t.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive rematch under Clientes
|
||||||
|
try {
|
||||||
|
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
|
||||||
|
console.log(
|
||||||
|
`Drive root=${clientesRoot?.name || '?'} folders=${folders.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manual preferred matches by name heuristics
|
||||||
|
const prefer: Record<string, RegExp> = {
|
||||||
|
ecp: /^(?:\d+[\.\-\s_]*)?(?:ecp|escuela\s*de\s*cuidadores|cuidadores)/i,
|
||||||
|
tsp: /^(?:\d+[\.\-\s_]*)?(?:tsp|talento\s*scholar|scholar)/i,
|
||||||
|
tyd: /^(?:\d+[\.\-\s_]*)?(?:06[\.\-\s_]*)?talento\s*y\s*deporte/i,
|
||||||
|
dtatf: /^(?:\d+[\.\-\s_]*)?(?:08[\.\-\s_]*)?(?:detuatu|de\s*tu\s*a\s*tu)/i,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const slug of FOCUS) {
|
||||||
|
const t = bySlug[slug];
|
||||||
|
if (!t) continue;
|
||||||
|
// skip Navidad-like noise
|
||||||
|
const candidates = folders
|
||||||
|
.map((f) => ({
|
||||||
|
f,
|
||||||
|
score: prefer[slug]?.test(f.name)
|
||||||
|
? 100
|
||||||
|
: scoreFolderToTenant(f.name, t.name, t.slug),
|
||||||
|
}))
|
||||||
|
.filter((x) => x.score >= 70 && !/navidad/i.test(x.f.name))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[drive candidates ${slug}]`,
|
||||||
|
candidates.slice(0, 5).map((c) => `${c.score}:${c.f.name}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const best = candidates[0];
|
||||||
|
if (!best) continue;
|
||||||
|
|
||||||
|
await rest(
|
||||||
|
`integrations?tenant_id=eq.${t.id}&provider=eq.google_drive`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: best.f.id,
|
||||||
|
label: best.f.name,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await rest(`tenants?id=eq.${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({ drive_folder_id: best.f.id }),
|
||||||
|
});
|
||||||
|
console.log(`[drive] ${slug} ← ${best.f.name} (${best.f.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also run generic matcher for any still missing
|
||||||
|
const still = await rest<
|
||||||
|
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
|
||||||
|
>(
|
||||||
|
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
|
||||||
|
);
|
||||||
|
const need = still.filter((t) => !t.drive_folder_id);
|
||||||
|
if (need.length) {
|
||||||
|
const matches = await matchFoldersToTenants(need, folders);
|
||||||
|
const good = matches.filter(
|
||||||
|
(m) =>
|
||||||
|
m.status === 'matched' &&
|
||||||
|
m.folderId &&
|
||||||
|
!/navidad/i.test(m.folderName || '')
|
||||||
|
);
|
||||||
|
if (good.length) {
|
||||||
|
await applyMatches(good);
|
||||||
|
for (const m of good) {
|
||||||
|
await rest(`tenants?id=eq.${m.tenantId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({ drive_folder_id: m.folderId }),
|
||||||
|
});
|
||||||
|
console.log(`[drive match] ${m.tenantSlug} ← ${m.folderName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Drive rematch failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich + ensure folders
|
||||||
|
for (const slug of FOCUS) {
|
||||||
|
const t = bySlug[slug];
|
||||||
|
if (!t) continue;
|
||||||
|
await rest(`tenants?id=eq.${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: BRAND_DISPLAY_NAMES[slug],
|
||||||
|
agency_notes: [
|
||||||
|
brandSiblingNote(slug),
|
||||||
|
`Código canónico: ${slug.toUpperCase()}.`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const r = await enrichTenantBrandbook({
|
||||||
|
tenantId: t.id,
|
||||||
|
forceSeed: true,
|
||||||
|
});
|
||||||
|
const drive = await ensureCreativesDriveFolders(t.id, slug);
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${slug} created=${r.created} ${r.completenessBefore}→${r.completenessAfter} sources=${r.sources.join('+')} drive=${drive.ok ? 'ok' : drive.warning}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify brandbooks
|
||||||
|
for (const slug of FOCUS) {
|
||||||
|
const t = bySlug[slug];
|
||||||
|
if (!t) continue;
|
||||||
|
const bb = await rest<{ id: string }[]>(
|
||||||
|
`documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`
|
||||||
|
);
|
||||||
|
console.log(`[verify] ${slug} brandbook=${bb[0]?.id || 'MISSING'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
450
scripts/gap_report_connections.py
Normal file
450
scripts/gap_report_connections.py
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exact gap report: visible assets vs tenant matches + name candidates."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
META = "https://graph.facebook.com/v21.0"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env() -> dict[str, str]:
|
||||||
|
vals: dict[str, str] = {}
|
||||||
|
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip() or line.strip().startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
vals[k.strip()] = v.strip().strip('"').strip("'")
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
ENV = load_env()
|
||||||
|
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||||
|
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
|
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||||
|
|
||||||
|
|
||||||
|
def sb(path: str):
|
||||||
|
req = urllib.request.Request(BASE + path, headers=SH)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
s = s.lower()
|
||||||
|
for a, b in (
|
||||||
|
("á", "a"),
|
||||||
|
("é", "e"),
|
||||||
|
("í", "i"),
|
||||||
|
("ó", "o"),
|
||||||
|
("ú", "u"),
|
||||||
|
("ñ", "n"),
|
||||||
|
):
|
||||||
|
s = s.replace(a, b)
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
|
||||||
|
t = norm(tenant_name)
|
||||||
|
s = norm(tenant_slug.replace("-", " "))
|
||||||
|
a = norm(account_name)
|
||||||
|
if not a:
|
||||||
|
return 0
|
||||||
|
if a == t or a == s:
|
||||||
|
return 100
|
||||||
|
if t and (t in a or a in t):
|
||||||
|
return 85
|
||||||
|
if s and (s in a or a in s):
|
||||||
|
return 80
|
||||||
|
tt = {x for x in t.split() if len(x) > 2}
|
||||||
|
aa = {x for x in a.split() if len(x) > 2}
|
||||||
|
if not tt or not aa:
|
||||||
|
return 0
|
||||||
|
hit = len(tt & aa) / len(tt)
|
||||||
|
if hit >= 0.8:
|
||||||
|
return 70
|
||||||
|
if hit >= 0.5:
|
||||||
|
return 55
|
||||||
|
return int(hit * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def top_candidates(tenants, account_name, n=3):
|
||||||
|
scored = []
|
||||||
|
for t in tenants:
|
||||||
|
sc = score_name(t["name"], t["slug"], account_name)
|
||||||
|
if sc > 0:
|
||||||
|
scored.append((sc, t["name"]))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
return scored[:n]
|
||||||
|
|
||||||
|
|
||||||
|
def meta_paginate(token: str, path: str):
|
||||||
|
out = []
|
||||||
|
url = f"{META}/{path}"
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
url = url + sep + "access_token=" + urllib.parse.quote(token)
|
||||||
|
while url:
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
out.extend(j.get("data") or [])
|
||||||
|
url = (j.get("paging") or {}).get("next")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def google_refresh(refresh: str) -> str:
|
||||||
|
body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
|
||||||
|
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tenants = sb("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
||||||
|
integs = sb(
|
||||||
|
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
||||||
|
"&select=tenant_id,provider,status,external_account_id,meta,error_message"
|
||||||
|
)
|
||||||
|
by = defaultdict(dict)
|
||||||
|
for i in integs:
|
||||||
|
by[i["tenant_id"]][i["provider"]] = i
|
||||||
|
|
||||||
|
grow = sb(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,account_email"
|
||||||
|
)[0]
|
||||||
|
mrow = sb(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes"
|
||||||
|
)[0]
|
||||||
|
gtoken = google_refresh(grow["refresh_token"])
|
||||||
|
mtoken = mrow["access_token"]
|
||||||
|
|
||||||
|
print("=== TOKENS ===")
|
||||||
|
print("Google:", grow.get("account_email"))
|
||||||
|
print("Meta:", mrow.get("account_email"))
|
||||||
|
|
||||||
|
# Meta inventory
|
||||||
|
ads = []
|
||||||
|
for row in meta_paginate(
|
||||||
|
mtoken, "me/adaccounts?fields=id,account_id,name&limit=200"
|
||||||
|
):
|
||||||
|
aid = row.get("account_id") or row["id"].replace("act_", "")
|
||||||
|
ads.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
||||||
|
pages = []
|
||||||
|
igs = []
|
||||||
|
for page in meta_paginate(
|
||||||
|
mtoken,
|
||||||
|
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
|
||||||
|
):
|
||||||
|
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
|
||||||
|
ig = page.get("instagram_business_account") or {}
|
||||||
|
if ig.get("id"):
|
||||||
|
igs.append(
|
||||||
|
{
|
||||||
|
"id": ig["id"],
|
||||||
|
"name": f"@{ig['username']}"
|
||||||
|
if ig.get("username")
|
||||||
|
else (ig.get("name") or page.get("name") or ig["id"]),
|
||||||
|
"page": page.get("name"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== META VISIBLE === ads={len(ads)} pages={len(pages)} ig={len(igs)}")
|
||||||
|
print("Ad accounts:")
|
||||||
|
for a in ads:
|
||||||
|
cands = top_candidates(tenants, a["name"])
|
||||||
|
linked = next(
|
||||||
|
(
|
||||||
|
t["name"]
|
||||||
|
for t in tenants
|
||||||
|
if (by[t["id"]].get("meta_ads") or {}).get("external_account_id")
|
||||||
|
== a["id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||||
|
)
|
||||||
|
print("Pages:")
|
||||||
|
for a in pages:
|
||||||
|
cands = top_candidates(tenants, a["name"])
|
||||||
|
linked = next(
|
||||||
|
(
|
||||||
|
t["name"]
|
||||||
|
for t in tenants
|
||||||
|
if (by[t["id"]].get("facebook") or {}).get("external_account_id")
|
||||||
|
== a["id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||||
|
)
|
||||||
|
print("Instagram:")
|
||||||
|
for a in igs:
|
||||||
|
cands = top_candidates(tenants, a["name"]) + top_candidates(
|
||||||
|
tenants, a.get("page") or ""
|
||||||
|
)
|
||||||
|
# dedupe keep best
|
||||||
|
seen = {}
|
||||||
|
for sc, name in cands:
|
||||||
|
seen[name] = max(sc, seen.get(name, 0))
|
||||||
|
cands = sorted(((v, k) for k, v in seen.items()), reverse=True)[:3]
|
||||||
|
linked = next(
|
||||||
|
(
|
||||||
|
t["name"]
|
||||||
|
for t in tenants
|
||||||
|
if (by[t["id"]].get("instagram") or {}).get("external_account_id")
|
||||||
|
== a["id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {a['name']!r} page={a.get('page')!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# GA4 / GSC inventory
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
summaries = json.loads(r.read().decode())
|
||||||
|
ga4 = []
|
||||||
|
for acc in summaries.get("accountSummaries") or []:
|
||||||
|
for p in acc.get("propertySummaries") or []:
|
||||||
|
if p.get("property"):
|
||||||
|
ga4.append(
|
||||||
|
{
|
||||||
|
"id": p["property"],
|
||||||
|
"name": p.get("displayName") or p["property"],
|
||||||
|
"account": acc.get("displayName"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
sites_j = json.loads(r.read().decode())
|
||||||
|
gsc = [
|
||||||
|
{"id": s["siteUrl"], "name": s["siteUrl"]}
|
||||||
|
for s in (sites_j.get("siteEntry") or [])
|
||||||
|
if s.get("siteUrl")
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"\n=== GOOGLE VISIBLE === ga4={len(ga4)} gsc={len(gsc)}")
|
||||||
|
|
||||||
|
# Test Data API on first property
|
||||||
|
if ga4:
|
||||||
|
prop = ga4[0]["id"].replace("properties/", "")
|
||||||
|
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
||||||
|
start = end - timedelta(days=2)
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"dateRanges": [{"startDate": str(start), "endDate": str(end)}],
|
||||||
|
"dimensions": [{"name": "date"}],
|
||||||
|
"metrics": [{"name": "sessions"}],
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {gtoken}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
print("Data API test: OK on", ga4[0]["name"], prop)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("Data API test: FAIL", e.read().decode()[:250])
|
||||||
|
|
||||||
|
# Unmatched GA4 with best candidates (only those not linked)
|
||||||
|
linked_ga4_ids = {
|
||||||
|
(by[t["id"]].get("ga4") or {}).get("external_account_id")
|
||||||
|
for t in tenants
|
||||||
|
}
|
||||||
|
linked_ga4_props = set()
|
||||||
|
for t in tenants:
|
||||||
|
meta = (by[t["id"]].get("ga4") or {}).get("meta") or {}
|
||||||
|
if meta.get("property_id"):
|
||||||
|
linked_ga4_props.add(meta["property_id"])
|
||||||
|
|
||||||
|
print("\n=== GA4 sin ficha clara (score<50 o no enlazado) — top candidatos ===")
|
||||||
|
for a in ga4:
|
||||||
|
linked = next(
|
||||||
|
(
|
||||||
|
t["name"]
|
||||||
|
for t in tenants
|
||||||
|
if (by[t["id"]].get("ga4") or {}).get("meta", {}).get("property_id")
|
||||||
|
== a["id"]
|
||||||
|
or (by[t["id"]].get("ga4") or {}).get("external_account_id")
|
||||||
|
in (a["id"], a["name"])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
cands = top_candidates(tenants, a["name"])
|
||||||
|
best = cands[0][0] if cands else 0
|
||||||
|
if linked and best >= 50:
|
||||||
|
continue
|
||||||
|
if linked:
|
||||||
|
continue
|
||||||
|
print(f" GA4 {a['name']!r} ({a['id']}) acct={a.get('account')!r} best={cands}")
|
||||||
|
|
||||||
|
print("\n=== GSC sin ficha clara ===")
|
||||||
|
for a in gsc:
|
||||||
|
linked = next(
|
||||||
|
(
|
||||||
|
t["name"]
|
||||||
|
for t in tenants
|
||||||
|
if (by[t["id"]].get("search_console") or {}).get("external_account_id")
|
||||||
|
== a["id"]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if linked:
|
||||||
|
continue
|
||||||
|
cands = top_candidates(tenants, a["name"])
|
||||||
|
# also try domain without sc-domain
|
||||||
|
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
||||||
|
cands2 = top_candidates(tenants, bare)
|
||||||
|
merged = {}
|
||||||
|
for sc, name in cands + cands2:
|
||||||
|
merged[name] = max(sc, merged.get(name, 0))
|
||||||
|
cands = sorted(((v, k) for k, v in merged.items()), reverse=True)[:3]
|
||||||
|
print(f" GSC {a['name']!r} best={cands}")
|
||||||
|
|
||||||
|
# Per-tenant gaps with why
|
||||||
|
print("\n=== POR CLIENTE: qué falta y por qué ===")
|
||||||
|
providers = [
|
||||||
|
"google_ads",
|
||||||
|
"meta_ads",
|
||||||
|
"ga4",
|
||||||
|
"search_console",
|
||||||
|
"google_drive",
|
||||||
|
"facebook",
|
||||||
|
"instagram",
|
||||||
|
]
|
||||||
|
|
||||||
|
def ok(i):
|
||||||
|
return bool(i and i.get("status") == "connected" and i.get("external_account_id"))
|
||||||
|
|
||||||
|
for t in tenants:
|
||||||
|
m = by[t["id"]]
|
||||||
|
gaps = []
|
||||||
|
for p in providers:
|
||||||
|
if ok(m.get(p)):
|
||||||
|
continue
|
||||||
|
if p == "meta_ads":
|
||||||
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ads]
|
||||||
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||||
|
if not ads:
|
||||||
|
gaps.append("meta_ads: token no ve ad accounts")
|
||||||
|
elif not cands:
|
||||||
|
gaps.append("meta_ads: no hay ad account cuyo nombre se parezca (¿no está en las 9 visibles?)")
|
||||||
|
else:
|
||||||
|
gaps.append(f"meta_ads: no auto-match; candidatos {cands}")
|
||||||
|
elif p == "facebook":
|
||||||
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in pages]
|
||||||
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||||
|
if not cands:
|
||||||
|
gaps.append("facebook: ninguna Page visible se parece al nombre")
|
||||||
|
else:
|
||||||
|
gaps.append(f"facebook: no auto-match; candidatos {cands}")
|
||||||
|
elif p == "instagram":
|
||||||
|
cands = []
|
||||||
|
for a in igs:
|
||||||
|
sc = max(
|
||||||
|
score_name(t["name"], t["slug"], a["name"]),
|
||||||
|
score_name(t["name"], t["slug"], a.get("page") or ""),
|
||||||
|
)
|
||||||
|
if sc > 0:
|
||||||
|
cands.append((sc, a["name"], a.get("page"), a["id"]))
|
||||||
|
cands = sorted(cands, reverse=True)[:2]
|
||||||
|
if not cands:
|
||||||
|
gaps.append("instagram: ningún @ / page visible se parece")
|
||||||
|
else:
|
||||||
|
gaps.append(f"instagram: no auto-match; candidatos {cands}")
|
||||||
|
elif p == "ga4":
|
||||||
|
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ga4]
|
||||||
|
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
|
||||||
|
if not cands:
|
||||||
|
gaps.append("ga4: ninguna propiedad visible se parece al nombre")
|
||||||
|
else:
|
||||||
|
gaps.append(f"ga4: no auto-match (umbral 50); candidatos {cands}")
|
||||||
|
elif p == "search_console":
|
||||||
|
cands = []
|
||||||
|
for a in gsc:
|
||||||
|
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
|
||||||
|
sc = max(
|
||||||
|
score_name(t["name"], t["slug"], a["name"]),
|
||||||
|
score_name(t["name"], t["slug"], bare),
|
||||||
|
)
|
||||||
|
if sc > 0:
|
||||||
|
cands.append((sc, a["name"]))
|
||||||
|
cands = sorted(cands, reverse=True)[:2]
|
||||||
|
if not cands:
|
||||||
|
gaps.append("gsc: ningún site visible se parece")
|
||||||
|
else:
|
||||||
|
gaps.append(f"gsc: no auto-match (umbral 40); candidatos {cands}")
|
||||||
|
elif p == "google_drive":
|
||||||
|
gaps.append("drive: sin carpeta auto-matched (revisar nombre carpeta Drive)")
|
||||||
|
elif p == "google_ads":
|
||||||
|
gaps.append("google_ads: sin cuenta enlazada (MCC / nombre)")
|
||||||
|
if gaps:
|
||||||
|
print(f"\n{t['name']}:")
|
||||||
|
for g in gaps:
|
||||||
|
print(f" - {g}")
|
||||||
|
else:
|
||||||
|
print(f"\n{t['name']}: COMPLETO (todos los canales con ID)")
|
||||||
|
|
||||||
|
# BPS GSC permission probe
|
||||||
|
bps = next(t for t in tenants if t["name"] == "BPS")
|
||||||
|
gsc_id = (by[bps["id"]].get("search_console") or {}).get("external_account_id")
|
||||||
|
if gsc_id:
|
||||||
|
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
|
||||||
|
start = end - timedelta(days=3)
|
||||||
|
body = json.dumps(
|
||||||
|
{"startDate": str(start), "endDate": str(end), "dimensions": ["date"]}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(gsc_id, safe='')}/searchAnalytics/query",
|
||||||
|
data=body,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {gtoken}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
print("\nBPS GSC probe: OK")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("\nBPS GSC probe: FAIL", e.read().decode()[:220])
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
22
scripts/generate_calendar_template.ts
Normal file
22
scripts/generate_calendar_template.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Regenerate public/plantillas/calendario-rrss-oficial.xlsx
|
||||||
|
* npx tsx scripts/generate_calendar_template.ts
|
||||||
|
*/
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { buildOfficialXlsx } from '../src/lib/calendar-template';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { buffer, filename } = await buildOfficialXlsx('CLIENTE');
|
||||||
|
const out = resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/plantillas/calendario-rrss-oficial.xlsx'
|
||||||
|
);
|
||||||
|
writeFileSync(out, buffer);
|
||||||
|
console.log('Wrote', out, `(${buffer.length} bytes) · ${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
96
scripts/heal_agency_roles.ts
Normal file
96
scripts/heal_agency_roles.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Asegura app_metadata.role=admin (o team) para operadores @4ventures.es.
|
||||||
|
* npx tsx scripts/heal_agency_roles.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
||||||
|
const COMPANY = '4Ventures';
|
||||||
|
const SLUG = '4ventures-hub';
|
||||||
|
|
||||||
|
const FORCE_ADMIN = [
|
||||||
|
'marketing@4ventures.es',
|
||||||
|
'marcos@4ventures.es',
|
||||||
|
'belen@4ventures.es',
|
||||||
|
'rebecca@4ventures.es',
|
||||||
|
'victor@4ventures.es',
|
||||||
|
'agencia@4ventures.es',
|
||||||
|
'ops@4ventures.es',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) throw new Error('Faltan env Supabase');
|
||||||
|
|
||||||
|
const admin = createClient(url, key, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emails = new Set(FORCE_ADMIN.map((e) => e.toLowerCase()));
|
||||||
|
let page = 1;
|
||||||
|
let fixed = 0;
|
||||||
|
let scanned = 0;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({
|
||||||
|
page,
|
||||||
|
perPage: 200,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
const users = data.users || [];
|
||||||
|
if (!users.length) break;
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
scanned += 1;
|
||||||
|
const email = (u.email || '').toLowerCase();
|
||||||
|
if (!email.endsWith('@4ventures.es') && !emails.has(email)) continue;
|
||||||
|
|
||||||
|
const appRole = String(u.app_metadata?.role || '');
|
||||||
|
if (appRole === 'admin' || appRole === 'team') {
|
||||||
|
// Ya OK; repara tenant/company si faltan
|
||||||
|
if (u.app_metadata?.tenant_id && u.app_metadata?.company) continue;
|
||||||
|
}
|
||||||
|
if (appRole === 'client') {
|
||||||
|
console.log(`skip client explícito: ${email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = emails.has(email) ? 'admin' : 'team';
|
||||||
|
const { error: updErr } = await admin.auth.admin.updateUserById(u.id, {
|
||||||
|
app_metadata: {
|
||||||
|
...(u.app_metadata || {}),
|
||||||
|
role,
|
||||||
|
tenant_id: u.app_metadata?.tenant_id || HUB_TENANT,
|
||||||
|
company: u.app_metadata?.company || COMPANY,
|
||||||
|
slug: u.app_metadata?.slug || SLUG,
|
||||||
|
},
|
||||||
|
user_metadata: {
|
||||||
|
...(u.user_metadata || {}),
|
||||||
|
role,
|
||||||
|
company: COMPANY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (updErr) {
|
||||||
|
console.error(`FAIL ${email}:`, updErr.message);
|
||||||
|
} else {
|
||||||
|
fixed += 1;
|
||||||
|
console.log(`OK ${email} → app_metadata.role=${role}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.length < 200) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scanned ${scanned}, fixed ${fixed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
457
scripts/import_bps_calendars.ts
Normal file
457
scripts/import_bps_calendars.ts
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* Import BPS editorial calendars from Drive xlsx into documents (type=calendar).
|
||||||
|
* Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26).
|
||||||
|
*
|
||||||
|
* Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx)
|
||||||
|
* npx tsx scripts/export_bps_xlsx.ts
|
||||||
|
* npx tsx scripts/import_bps_calendars.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||||
|
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
|
||||||
|
|
||||||
|
const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit year/month for every editorial month sheet (incl. hidden).
|
||||||
|
* Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26.
|
||||||
|
*/
|
||||||
|
const SHEET_YM: Record<string, string> = {
|
||||||
|
JULIO: '2025-07',
|
||||||
|
AGOSTO: '2025-08',
|
||||||
|
SEPTIEMBRE: '2025-09',
|
||||||
|
OCTUBRE: '2025-10',
|
||||||
|
NOVIEMBRE: '2025-11',
|
||||||
|
DICIEMBRE: '2025-12',
|
||||||
|
'ENERO 2026': '2026-01',
|
||||||
|
FEBRERO: '2026-02',
|
||||||
|
MARZO: '2026-03',
|
||||||
|
ABRIL: '2026-04',
|
||||||
|
MAYO: '2026-05',
|
||||||
|
JUNIO: '2026-06',
|
||||||
|
'JULIO 26': '2026-07',
|
||||||
|
'AGOSTO 26': '2026-08',
|
||||||
|
'SEPTIMEBRE 26': '2026-09',
|
||||||
|
'OCTUBRE 26': '2026-10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM));
|
||||||
|
|
||||||
|
function slugify(s: string) {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDayHeader(h: string): number | null {
|
||||||
|
const raw = String(h || '').trim();
|
||||||
|
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null;
|
||||||
|
const m = raw.match(/(\d{1,2})\s*$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
return n >= 1 && n <= 31 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SheetRows = (string | number | null)[][];
|
||||||
|
|
||||||
|
/** Parse SEMANA/STORIES/POST grid into pieces. */
|
||||||
|
function parseWeekGrid(
|
||||||
|
sheetName: string,
|
||||||
|
rows: SheetRows,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): RrssPiece[] {
|
||||||
|
const pieces: RrssPiece[] = [];
|
||||||
|
let dayCols: { col: number; day: number }[] = [];
|
||||||
|
let kind: 'STORIES' | 'POST' | null = null;
|
||||||
|
/** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */
|
||||||
|
let weekWraps = false;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || !row.some((c) => c != null && String(c).trim())) continue;
|
||||||
|
const c0 = String(row[0] ?? '')
|
||||||
|
.trim()
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
c0.startsWith('SEMANA') ||
|
||||||
|
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
|
||||||
|
) {
|
||||||
|
dayCols = [];
|
||||||
|
for (let col = 1; col < Math.min(row.length, 10); col++) {
|
||||||
|
const day = parseDayHeader(String(row[col] ?? ''));
|
||||||
|
if (day) dayCols.push({ col, day });
|
||||||
|
}
|
||||||
|
weekWraps = dayCols.some(
|
||||||
|
(d, i) =>
|
||||||
|
i > 0 &&
|
||||||
|
d.day < dayCols[i - 1].day &&
|
||||||
|
dayCols[i - 1].day >= 28 &&
|
||||||
|
d.day <= 7
|
||||||
|
);
|
||||||
|
kind = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c0.startsWith('STORIES') || c0 === 'STORY') {
|
||||||
|
kind = 'STORIES';
|
||||||
|
} else if (c0.startsWith('POST')) {
|
||||||
|
kind = 'POST';
|
||||||
|
} else if (!kind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { col, day } of dayCols) {
|
||||||
|
const raw = row[col];
|
||||||
|
if (raw == null) continue;
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (!text || text === 'X' || /^columna/i.test(text)) continue;
|
||||||
|
|
||||||
|
let y = year;
|
||||||
|
let m = month;
|
||||||
|
if (weekWraps && day >= 28) {
|
||||||
|
m = month - 1;
|
||||||
|
if (m < 1) {
|
||||||
|
m = 12;
|
||||||
|
y = year - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
const dd = String(day).padStart(2, '0');
|
||||||
|
const date = `${y}-${mm}-${dd}`;
|
||||||
|
const dt = new Date(`${date}T12:00:00Z`);
|
||||||
|
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
|
||||||
|
|
||||||
|
const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`;
|
||||||
|
pieces.push({
|
||||||
|
id,
|
||||||
|
date,
|
||||||
|
platform: 'Instagram',
|
||||||
|
format: kind === 'STORIES' ? 'Stories' : 'Post',
|
||||||
|
pillar: kind === 'STORIES' ? 'stories' : 'feed',
|
||||||
|
title: text.split('\n')[0].slice(0, 120),
|
||||||
|
body: text,
|
||||||
|
status: 'planned',
|
||||||
|
kind: kind === 'STORIES' ? 'story' : 'post',
|
||||||
|
color: kind === 'STORIES' ? '#D9821F' : '#2071cf',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return pieces.filter((p) => {
|
||||||
|
if (seen.has(p.id)) return false;
|
||||||
|
seen.add(p.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSheetsFromPython(): Promise<
|
||||||
|
{ name: string; rows: SheetRows; state: string }[]
|
||||||
|
> {
|
||||||
|
const py = `
|
||||||
|
import openpyxl, json
|
||||||
|
wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True)
|
||||||
|
out=[]
|
||||||
|
month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}}
|
||||||
|
for name in wb.sheetnames:
|
||||||
|
ws=wb[name]
|
||||||
|
keep = name in month_names or name == 'Calendario'
|
||||||
|
if not keep:
|
||||||
|
continue
|
||||||
|
rows=[]
|
||||||
|
# Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak
|
||||||
|
blank=0
|
||||||
|
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1):
|
||||||
|
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
|
||||||
|
if not any(v is not None and str(v).strip() for v in vals):
|
||||||
|
blank+=1
|
||||||
|
if blank>15 and i>30: break
|
||||||
|
continue
|
||||||
|
blank=0
|
||||||
|
rows.append(vals)
|
||||||
|
out.append({'name': name, 'rows': rows, 'state': ws.sheet_state})
|
||||||
|
try:
|
||||||
|
wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True)
|
||||||
|
for name in wb2.sheetnames:
|
||||||
|
ws=wb2[name]
|
||||||
|
rows=[]
|
||||||
|
blank=0
|
||||||
|
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1):
|
||||||
|
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
|
||||||
|
if not any(v is not None and str(v).strip() for v in vals):
|
||||||
|
blank+=1
|
||||||
|
if blank>10: break
|
||||||
|
continue
|
||||||
|
blank=0
|
||||||
|
rows.append(vals)
|
||||||
|
out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(json.dumps(out, ensure_ascii=False, default=str))
|
||||||
|
`;
|
||||||
|
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 });
|
||||||
|
return JSON.parse(raw.toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStructuredCalendario(rows: SheetRows): Map<string, RrssPiece[]> {
|
||||||
|
const byMonth = new Map<string, RrssPiece[]>();
|
||||||
|
for (const r of rows.slice(1)) {
|
||||||
|
if (!r) continue;
|
||||||
|
const dia = r[2];
|
||||||
|
const formato = String(r[3] ?? '');
|
||||||
|
const contenido = String(r[4] ?? '').trim();
|
||||||
|
const estado = String(r[6] ?? '');
|
||||||
|
if (!contenido && !formato) continue;
|
||||||
|
let dateStr = '';
|
||||||
|
if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) {
|
||||||
|
dateStr = dia.slice(0, 10);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const monthKey = dateStr.slice(0, 7);
|
||||||
|
const isStory = /storie/i.test(formato);
|
||||||
|
const isBlog = /blog/i.test(formato);
|
||||||
|
const piece: RrssPiece = {
|
||||||
|
id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`,
|
||||||
|
date: dateStr,
|
||||||
|
platform: isBlog ? 'Blog' : 'Instagram',
|
||||||
|
format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post',
|
||||||
|
pillar: isStory ? 'stories' : 'feed',
|
||||||
|
title: (contenido || formato).split('\n')[0].slice(0, 120) || formato,
|
||||||
|
body: contenido || undefined,
|
||||||
|
status: /subido/i.test(estado)
|
||||||
|
? 'published'
|
||||||
|
: /pendiente/i.test(estado)
|
||||||
|
? 'planned'
|
||||||
|
: 'planned',
|
||||||
|
kind: isStory ? 'story' : 'post',
|
||||||
|
color: isStory ? '#D9821F' : '#2071cf',
|
||||||
|
};
|
||||||
|
const list = byMonth.get(monthKey) || [];
|
||||||
|
list.push(piece);
|
||||||
|
byMonth.set(monthKey, list);
|
||||||
|
}
|
||||||
|
return byMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(ym: string) {
|
||||||
|
const [y, m] = ym.split('-').map(Number);
|
||||||
|
const names = [
|
||||||
|
'',
|
||||||
|
'Enero',
|
||||||
|
'Febrero',
|
||||||
|
'Marzo',
|
||||||
|
'Abril',
|
||||||
|
'Mayo',
|
||||||
|
'Junio',
|
||||||
|
'Julio',
|
||||||
|
'Agosto',
|
||||||
|
'Septiembre',
|
||||||
|
'Octubre',
|
||||||
|
'Noviembre',
|
||||||
|
'Diciembre',
|
||||||
|
];
|
||||||
|
return `${names[m]} ${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan {
|
||||||
|
const posts = pieces.filter((p) => p.kind !== 'story').length;
|
||||||
|
const stories = pieces.filter((p) => p.kind === 'story').length;
|
||||||
|
return {
|
||||||
|
kind: 'rrss_month',
|
||||||
|
month: ym,
|
||||||
|
label: monthLabel(ym),
|
||||||
|
hero: {
|
||||||
|
eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS',
|
||||||
|
title: `Calendario editorial · ${monthLabel(ym)}`,
|
||||||
|
lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.',
|
||||||
|
chips: [
|
||||||
|
`${posts} posts`,
|
||||||
|
`${stories} stories`,
|
||||||
|
'Fuente: Google Drive',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPlan(plan: RrssMonthPlan) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) throw new Error('no service role');
|
||||||
|
const base = supabaseUrl();
|
||||||
|
const title = `RRSS ${plan.label}`;
|
||||||
|
const existing = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
const docs = (await existing.json()) as {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: unknown;
|
||||||
|
}[];
|
||||||
|
for (const d of docs) {
|
||||||
|
let c = d.content as RrssMonthPlan | string | null;
|
||||||
|
if (typeof c === 'string') {
|
||||||
|
try {
|
||||||
|
c = JSON.parse(c) as RrssMonthPlan;
|
||||||
|
} catch {
|
||||||
|
c = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const month = c && typeof c === 'object' ? c.month : null;
|
||||||
|
const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`;
|
||||||
|
if (month === plan.month || sameTitle) {
|
||||||
|
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { ...headers, Prefer: 'return=minimal' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const row = {
|
||||||
|
tenant_id: BPS_ID,
|
||||||
|
type: 'calendar',
|
||||||
|
title,
|
||||||
|
content: plan,
|
||||||
|
status: 'approved',
|
||||||
|
};
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(row),
|
||||||
|
});
|
||||||
|
if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`);
|
||||||
|
const saved = (await ins.json()) as { id: string }[];
|
||||||
|
console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] {
|
||||||
|
const ids = new Set(a.map((p) => p.id));
|
||||||
|
return a.concat(b.filter((p) => !ids.has(p.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const sheets = await loadSheetsFromPython();
|
||||||
|
const byMonth = new Map<string, RrssPiece[]>();
|
||||||
|
const gridMonths = new Set<string>();
|
||||||
|
|
||||||
|
// 1) All month tabs (hidden + visible) — primary source
|
||||||
|
for (const sh of sheets) {
|
||||||
|
const baseName = sh.name.replace(' (prov)', '');
|
||||||
|
const ym = SHEET_YM[baseName];
|
||||||
|
if (!ym) continue;
|
||||||
|
const [year, month] = ym.split('-').map(Number);
|
||||||
|
const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month);
|
||||||
|
const cur = byMonth.get(ym) || [];
|
||||||
|
if (sh.name.includes('(prov)') && pieces.length >= cur.length) {
|
||||||
|
byMonth.set(ym, pieces);
|
||||||
|
} else {
|
||||||
|
byMonth.set(ym, mergePieces(cur, pieces));
|
||||||
|
}
|
||||||
|
if (pieces.length) gridMonths.add(ym);
|
||||||
|
console.log(
|
||||||
|
`parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab
|
||||||
|
// (May–Aug 2025 detail) or merge blog rows into existing months
|
||||||
|
for (const sh of sheets) {
|
||||||
|
if (sh.name !== 'Calendario') continue;
|
||||||
|
const structured = parseStructuredCalendario(sh.rows as SheetRows);
|
||||||
|
for (const [ym, pieces] of structured) {
|
||||||
|
if (gridMonths.has(ym)) {
|
||||||
|
// keep grid as source of truth; still add blog-only pieces
|
||||||
|
const blogs = pieces.filter((p) => p.platform === 'Blog');
|
||||||
|
if (blogs.length) {
|
||||||
|
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs));
|
||||||
|
console.log(`calendario blogs → ${ym} +${blogs.length}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
|
||||||
|
console.log(`calendario only → ${ym} ${pieces.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025)
|
||||||
|
const keepMonths = new Set(byMonth.keys());
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
'/tmp/bps-cal/plans-summary.json',
|
||||||
|
JSON.stringify(
|
||||||
|
[...byMonth.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([m, p]) => ({ month: m, n: p.length })),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (headers && base) {
|
||||||
|
const existing = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
const docs = (await existing.json()) as {
|
||||||
|
id: string;
|
||||||
|
content: unknown;
|
||||||
|
}[];
|
||||||
|
for (const d of docs) {
|
||||||
|
let c = d.content as { month?: string } | string | null;
|
||||||
|
if (typeof c === 'string') {
|
||||||
|
try {
|
||||||
|
c = JSON.parse(c) as { month?: string };
|
||||||
|
} catch {
|
||||||
|
c = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const m = c && typeof c === 'object' ? c.month : null;
|
||||||
|
if (!m) continue;
|
||||||
|
// Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets
|
||||||
|
if (
|
||||||
|
/^2025-0[2-4]$/.test(m) &&
|
||||||
|
keepMonths.has(m.replace('2025', '2026'))
|
||||||
|
) {
|
||||||
|
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { ...headers, Prefer: 'return=minimal' },
|
||||||
|
});
|
||||||
|
console.log('deleted stale', m, d.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ym, pieces] of [...byMonth.entries()].sort()) {
|
||||||
|
if (!pieces.length) continue;
|
||||||
|
await upsertPlan(buildPlan(ym, pieces));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'months',
|
||||||
|
[...byMonth.keys()].sort().join(', '),
|
||||||
|
'total pieces',
|
||||||
|
[...byMonth.values()].reduce((s, p) => s + p.length, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
627
scripts/import_drive_calendars_batch.ts
Normal file
627
scripts/import_drive_calendars_batch.ts
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
/**
|
||||||
|
* Import editorial RRSS calendars from Drive export folder into documents.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/import_drive_calendars_batch.ts
|
||||||
|
*
|
||||||
|
* Source: /Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001
|
||||||
|
* Months per client as requested by ops (Oct 2026).
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||||
|
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
|
||||||
|
|
||||||
|
const ROOT =
|
||||||
|
'/Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001';
|
||||||
|
|
||||||
|
type SheetRows = (string | number | null)[][];
|
||||||
|
|
||||||
|
type ClientJob = {
|
||||||
|
slug: string;
|
||||||
|
brand: string;
|
||||||
|
file: string;
|
||||||
|
/** sheet name → year-month */
|
||||||
|
sheets: Record<string, string>;
|
||||||
|
/** if set, only upsert these months (others parsed ignored) */
|
||||||
|
onlyMonths: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const JOBS: ClientJob[] = [
|
||||||
|
{
|
||||||
|
slug: 'bps',
|
||||||
|
brand: 'Blue Parrot School',
|
||||||
|
file: `${ROOT}/BLUE PARROT/CALENDARIO Blue Parrot School.xlsx`,
|
||||||
|
sheets: {
|
||||||
|
JULIO: '2025-07',
|
||||||
|
AGOSTO: '2025-08',
|
||||||
|
SEPTIEMBRE: '2025-09',
|
||||||
|
OCTUBRE: '2025-10',
|
||||||
|
NOVIEMBRE: '2025-11',
|
||||||
|
DICIEMBRE: '2025-12',
|
||||||
|
'ENERO 2026': '2026-01',
|
||||||
|
FEBRERO: '2026-02',
|
||||||
|
MARZO: '2026-03',
|
||||||
|
ABRIL: '2026-04',
|
||||||
|
MAYO: '2026-05',
|
||||||
|
JUNIO: '2026-06',
|
||||||
|
'JULIO 26': '2026-07',
|
||||||
|
'AGOSTO 26': '2026-08',
|
||||||
|
'SEPTIMEBRE 26': '2026-09',
|
||||||
|
'OCTUBRE 26': '2026-10',
|
||||||
|
},
|
||||||
|
onlyMonths: [
|
||||||
|
'2025-05',
|
||||||
|
'2025-06',
|
||||||
|
'2025-07',
|
||||||
|
'2025-08',
|
||||||
|
'2025-09',
|
||||||
|
'2025-10',
|
||||||
|
'2025-11',
|
||||||
|
'2025-12',
|
||||||
|
'2026-01',
|
||||||
|
'2026-02',
|
||||||
|
'2026-03',
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
'2026-08',
|
||||||
|
'2026-09',
|
||||||
|
'2026-10',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'brava-dermoestetica',
|
||||||
|
brand: 'Brava Dermoestética',
|
||||||
|
file: `${ROOT}/BRAVA/CALENDARIO RRSS BRAVA.xlsx`,
|
||||||
|
sheets: {
|
||||||
|
'ABRIL 2026': '2026-04',
|
||||||
|
'MAYO 2026': '2026-05',
|
||||||
|
'JUNIO 2026': '2026-06',
|
||||||
|
'JULIO 2026': '2026-07',
|
||||||
|
'AGOSTO 2026': '2026-08',
|
||||||
|
'SEPTIEMBRE 2026': '2026-09',
|
||||||
|
'Octubre 2026': '2026-10',
|
||||||
|
},
|
||||||
|
onlyMonths: [
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
'2026-08',
|
||||||
|
'2026-09',
|
||||||
|
'2026-10',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'tyd',
|
||||||
|
brand: 'Talento y Deporte',
|
||||||
|
file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`,
|
||||||
|
sheets: {
|
||||||
|
AGOSTO: '2026-08',
|
||||||
|
SEPTIEMBRE: '2026-09',
|
||||||
|
OCTUBRE26: '2026-10',
|
||||||
|
},
|
||||||
|
onlyMonths: ['2026-08', '2026-09', '2026-10'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'halal-safety',
|
||||||
|
brand: 'Halal Safety',
|
||||||
|
file: `${ROOT}/HALAL/CALENDARIO Halal.xlsx`,
|
||||||
|
sheets: { 'Hoja 1': '2026-10' },
|
||||||
|
onlyMonths: ['2026-10'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'noberplast',
|
||||||
|
brand: 'Noberplast',
|
||||||
|
file: `${ROOT}/NOBERPLAST/CALENDARIO NOBERPLAST.xlsx`,
|
||||||
|
sheets: { 'Hoja 1': '2026-10' },
|
||||||
|
onlyMonths: ['2026-10'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: '4ventures-hub',
|
||||||
|
brand: '4Ventures',
|
||||||
|
file: `${ROOT}/4VENTURES/CALENDARIO 4VENTURES.xlsx`,
|
||||||
|
sheets: {
|
||||||
|
ABRIL: '2026-04',
|
||||||
|
MAYO: '2026-05',
|
||||||
|
JUNIO: '2026-06',
|
||||||
|
JULIO: '2026-07',
|
||||||
|
AGOSTO: '2026-08',
|
||||||
|
'SEPTIEMBRE 2026': '2026-09',
|
||||||
|
'OCTUBRE 2026': '2026-10',
|
||||||
|
},
|
||||||
|
onlyMonths: [
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
'2026-08',
|
||||||
|
'2026-09',
|
||||||
|
'2026-10',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'agua-miia',
|
||||||
|
brand: 'Agua Miia',
|
||||||
|
// Prefer live Drive export: python3 scripts/import_drive_calendars_live.py
|
||||||
|
file: `/tmp/drive-cals/agua-miia.xlsx`,
|
||||||
|
sheets: {
|
||||||
|
OCTUBRE: '2025-10',
|
||||||
|
NOVIEMBRE: '2025-11',
|
||||||
|
DICIEMBRE: '2025-12',
|
||||||
|
ENERO: '2026-01',
|
||||||
|
FEBRERO: '2026-02',
|
||||||
|
MARZO: '2026-03',
|
||||||
|
ABRIL: '2026-04',
|
||||||
|
MAYO: '2026-05',
|
||||||
|
JUNIO: '2026-06',
|
||||||
|
JULIO: '2026-07',
|
||||||
|
AGOSTO: '2026-08',
|
||||||
|
'SEPTIEMBRE 2026': '2026-09',
|
||||||
|
'OCTUBRE 2026': '2026-10',
|
||||||
|
},
|
||||||
|
onlyMonths: [
|
||||||
|
'2025-10',
|
||||||
|
'2025-11',
|
||||||
|
'2025-12',
|
||||||
|
'2026-01',
|
||||||
|
'2026-02',
|
||||||
|
'2026-03',
|
||||||
|
'2026-04',
|
||||||
|
'2026-05',
|
||||||
|
'2026-06',
|
||||||
|
'2026-07',
|
||||||
|
'2026-08',
|
||||||
|
'2026-09',
|
||||||
|
'2026-10',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function slugify(s: string) {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDayHeader(h: string): number | null {
|
||||||
|
const raw = String(h || '').trim();
|
||||||
|
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null;
|
||||||
|
const m = raw.match(/(\d{1,2})\s*$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
return n >= 1 && n <= 31 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectKind(
|
||||||
|
c0: string
|
||||||
|
): 'STORIES' | 'POST' | 'LINKEDIN' | null {
|
||||||
|
const u = c0.toUpperCase();
|
||||||
|
if (u.startsWith('STORIES') || u === 'STORY' || u.startsWith('STORY '))
|
||||||
|
return 'STORIES';
|
||||||
|
if (u.startsWith('LINKEDIN')) return 'LINKEDIN';
|
||||||
|
if (u.startsWith('POST') || u.startsWith('REEL') || u.startsWith('CARRUSEL'))
|
||||||
|
return 'POST';
|
||||||
|
// Brava uses "Story" row label
|
||||||
|
if (u === 'STORY' || u.startsWith('HISTORIA')) return 'STORIES';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pieceMeta(kind: 'STORIES' | 'POST' | 'LINKEDIN') {
|
||||||
|
if (kind === 'STORIES') {
|
||||||
|
return {
|
||||||
|
platform: 'Instagram',
|
||||||
|
format: 'Stories',
|
||||||
|
pillar: 'stories',
|
||||||
|
kind: 'story',
|
||||||
|
color: '#D9821F',
|
||||||
|
suffix: 'st',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (kind === 'LINKEDIN') {
|
||||||
|
return {
|
||||||
|
platform: 'LinkedIn',
|
||||||
|
format: 'Post',
|
||||||
|
pillar: 'linkedin',
|
||||||
|
kind: 'post',
|
||||||
|
color: '#7C66B8',
|
||||||
|
suffix: 'li',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
platform: 'Instagram',
|
||||||
|
format: 'Post',
|
||||||
|
pillar: 'feed',
|
||||||
|
kind: 'post',
|
||||||
|
color: '#2071cf',
|
||||||
|
suffix: 'post',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse SEMANA × STORIES/POST/LINKEDIN (and Brava Story/POST) grids. */
|
||||||
|
function parseWeekGrid(
|
||||||
|
prefix: string,
|
||||||
|
rows: SheetRows,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): RrssPiece[] {
|
||||||
|
const pieces: RrssPiece[] = [];
|
||||||
|
let dayCols: { col: number; day: number }[] = [];
|
||||||
|
let kind: 'STORIES' | 'POST' | 'LINKEDIN' | null = null;
|
||||||
|
let weekWraps = false;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || !row.some((c) => c != null && String(c).trim())) continue;
|
||||||
|
const c0 = String(row[0] ?? '').trim();
|
||||||
|
const c0u = c0.toUpperCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
c0u.startsWith('SEMANA') ||
|
||||||
|
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
|
||||||
|
) {
|
||||||
|
dayCols = [];
|
||||||
|
for (let col = 1; col < Math.min(row.length, 12); col++) {
|
||||||
|
const day = parseDayHeader(String(row[col] ?? ''));
|
||||||
|
if (day) dayCols.push({ col, day });
|
||||||
|
}
|
||||||
|
weekWraps = dayCols.some(
|
||||||
|
(d, i) =>
|
||||||
|
i > 0 &&
|
||||||
|
d.day < dayCols[i - 1]!.day &&
|
||||||
|
dayCols[i - 1]!.day >= 28 &&
|
||||||
|
d.day <= 7
|
||||||
|
);
|
||||||
|
kind = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip status-only Brava rows
|
||||||
|
if (
|
||||||
|
/^(PUBLICADO|CLIENTE|APROBADO)$/i.test(c0u) ||
|
||||||
|
c0u.startsWith('GUIONES') ||
|
||||||
|
c0u.startsWith('CARPETA')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detected = detectKind(c0u);
|
||||||
|
if (detected) kind = detected;
|
||||||
|
else if (!kind) continue;
|
||||||
|
|
||||||
|
if (!dayCols.length) continue;
|
||||||
|
const meta = pieceMeta(kind);
|
||||||
|
|
||||||
|
for (const { col, day } of dayCols) {
|
||||||
|
const raw = row[col];
|
||||||
|
if (raw == null) continue;
|
||||||
|
const text = String(raw).trim();
|
||||||
|
if (
|
||||||
|
!text ||
|
||||||
|
text === 'X' ||
|
||||||
|
/^columna/i.test(text) ||
|
||||||
|
/^(PUBLICADO|APROBADO)$/i.test(text)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
let y = year;
|
||||||
|
let m = month;
|
||||||
|
if (weekWraps && day >= 28) {
|
||||||
|
m = month - 1;
|
||||||
|
if (m < 1) {
|
||||||
|
m = 12;
|
||||||
|
y = year - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
const dd = String(day).padStart(2, '0');
|
||||||
|
const date = `${y}-${mm}-${dd}`;
|
||||||
|
const dt = new Date(`${date}T12:00:00Z`);
|
||||||
|
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
|
||||||
|
|
||||||
|
const id = `${prefix}-${date}-${meta.suffix}-${slugify(text).slice(0, 24)}`;
|
||||||
|
pieces.push({
|
||||||
|
id,
|
||||||
|
date,
|
||||||
|
platform: meta.platform,
|
||||||
|
format: meta.format,
|
||||||
|
pillar: meta.pillar,
|
||||||
|
title: text.split('\n')[0]!.slice(0, 120),
|
||||||
|
body: text,
|
||||||
|
status: 'planned',
|
||||||
|
kind: meta.kind,
|
||||||
|
color: meta.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return pieces.filter((p) => {
|
||||||
|
if (seen.has(p.id)) return false;
|
||||||
|
seen.add(p.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Structured BPS "Calendario" tab (fecha | formato | contenido). */
|
||||||
|
function parseStructuredCalendario(
|
||||||
|
prefix: string,
|
||||||
|
rows: SheetRows
|
||||||
|
): Map<string, RrssPiece[]> {
|
||||||
|
const byMonth = new Map<string, RrssPiece[]>();
|
||||||
|
for (const r of rows.slice(1)) {
|
||||||
|
if (!r) continue;
|
||||||
|
const dia = r[2];
|
||||||
|
const formato = String(r[3] ?? '');
|
||||||
|
const contenido = String(r[4] ?? '').trim();
|
||||||
|
const estado = String(r[6] ?? '');
|
||||||
|
if (!contenido && !formato) continue;
|
||||||
|
let dateStr = '';
|
||||||
|
if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) {
|
||||||
|
dateStr = dia.slice(0, 10);
|
||||||
|
} else continue;
|
||||||
|
const monthKey = dateStr.slice(0, 7);
|
||||||
|
const isStory = /storie/i.test(formato);
|
||||||
|
const isBlog = /blog/i.test(formato);
|
||||||
|
const piece: RrssPiece = {
|
||||||
|
id: `${prefix}-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`,
|
||||||
|
date: dateStr,
|
||||||
|
platform: isBlog ? 'Blog' : 'Instagram',
|
||||||
|
format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post',
|
||||||
|
pillar: isStory ? 'stories' : 'feed',
|
||||||
|
title: (contenido || formato).split('\n')[0]!.slice(0, 120) || formato,
|
||||||
|
body: contenido || undefined,
|
||||||
|
status: /subido/i.test(estado) ? 'published' : 'planned',
|
||||||
|
kind: isStory ? 'story' : 'post',
|
||||||
|
color: isStory ? '#D9821F' : '#2071cf',
|
||||||
|
};
|
||||||
|
const list = byMonth.get(monthKey) || [];
|
||||||
|
list.push(piece);
|
||||||
|
byMonth.set(monthKey, list);
|
||||||
|
}
|
||||||
|
return byMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSheets(
|
||||||
|
file: string,
|
||||||
|
wanted: Set<string>
|
||||||
|
): { name: string; rows: SheetRows }[] {
|
||||||
|
const py = `
|
||||||
|
import openpyxl, json, sys
|
||||||
|
wb=openpyxl.load_workbook(${JSON.stringify(file)}, data_only=True)
|
||||||
|
wanted=set(${JSON.stringify([...wanted])})
|
||||||
|
out=[]
|
||||||
|
for name in wb.sheetnames:
|
||||||
|
if wanted and name not in wanted and name != 'Calendario':
|
||||||
|
continue
|
||||||
|
ws=wb[name]
|
||||||
|
rows=[]
|
||||||
|
blank=0
|
||||||
|
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row or 1, 220), values_only=True),1):
|
||||||
|
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
|
||||||
|
if not any(v is not None and str(v).strip() for v in vals):
|
||||||
|
blank+=1
|
||||||
|
if blank>15 and i>30: break
|
||||||
|
continue
|
||||||
|
blank=0
|
||||||
|
rows.append(vals)
|
||||||
|
out.append({'name': name, 'rows': rows})
|
||||||
|
print(json.dumps(out, ensure_ascii=False, default=str))
|
||||||
|
`;
|
||||||
|
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 30_000_000 });
|
||||||
|
return JSON.parse(raw.toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(ym: string) {
|
||||||
|
const [, m] = ym.split('-').map(Number);
|
||||||
|
const names = [
|
||||||
|
'',
|
||||||
|
'Enero',
|
||||||
|
'Febrero',
|
||||||
|
'Marzo',
|
||||||
|
'Abril',
|
||||||
|
'Mayo',
|
||||||
|
'Junio',
|
||||||
|
'Julio',
|
||||||
|
'Agosto',
|
||||||
|
'Septiembre',
|
||||||
|
'Octubre',
|
||||||
|
'Noviembre',
|
||||||
|
'Diciembre',
|
||||||
|
];
|
||||||
|
return `${names[m!]} ${ym.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlan(
|
||||||
|
brand: string,
|
||||||
|
ym: string,
|
||||||
|
pieces: RrssPiece[],
|
||||||
|
sourceFile: string
|
||||||
|
): RrssMonthPlan {
|
||||||
|
const posts = pieces.filter((p) => p.kind !== 'story').length;
|
||||||
|
const stories = pieces.filter((p) => p.kind === 'story').length;
|
||||||
|
const fileName = sourceFile.split('/').pop() || sourceFile;
|
||||||
|
return {
|
||||||
|
kind: 'rrss_month',
|
||||||
|
month: ym,
|
||||||
|
label: monthLabel(ym),
|
||||||
|
hero: {
|
||||||
|
eyebrow: `4VENTURES × ${brand.toUpperCase()} · PLAN RRSS`,
|
||||||
|
title: `Calendario editorial · ${monthLabel(ym)}`,
|
||||||
|
lede: `Importado desde Drive (${fileName}).`,
|
||||||
|
chips: [
|
||||||
|
`${posts} posts`,
|
||||||
|
`${stories} stories`,
|
||||||
|
'Fuente: Google Drive',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] {
|
||||||
|
const ids = new Set(a.map((p) => p.id));
|
||||||
|
return a.concat(b.filter((p) => !ids.has(p.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTenantId(slug: string): Promise<string> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) throw new Error('no service role');
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const rows = (await r.json()) as { id: string }[];
|
||||||
|
if (!rows[0]?.id) throw new Error(`tenant not found: ${slug}`);
|
||||||
|
return rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertPlan(
|
||||||
|
tenantId: string,
|
||||||
|
plan: RrssMonthPlan
|
||||||
|
): Promise<void> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) throw new Error('no service role');
|
||||||
|
const title = `RRSS ${plan.label}`;
|
||||||
|
|
||||||
|
const existing = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${tenantId}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
const docs = (await existing.json()) as {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: unknown;
|
||||||
|
}[];
|
||||||
|
for (const d of docs) {
|
||||||
|
let c = d.content as RrssMonthPlan | string | null;
|
||||||
|
if (typeof c === 'string') {
|
||||||
|
try {
|
||||||
|
c = JSON.parse(c) as RrssMonthPlan;
|
||||||
|
} catch {
|
||||||
|
c = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const month = c && typeof c === 'object' ? c.month : null;
|
||||||
|
const sameTitle =
|
||||||
|
d.title === title ||
|
||||||
|
d.title.toLowerCase().includes(plan.label.toLowerCase());
|
||||||
|
if (month === plan.month || sameTitle) {
|
||||||
|
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { ...headers, Prefer: 'return=minimal' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: 'calendar',
|
||||||
|
title,
|
||||||
|
content: plan,
|
||||||
|
status: 'approved',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) {
|
||||||
|
throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
const saved = (await ins.json()) as { id: string }[];
|
||||||
|
console.log(
|
||||||
|
' OK',
|
||||||
|
plan.month,
|
||||||
|
plan.pieces.length,
|
||||||
|
'pieces',
|
||||||
|
saved[0]?.id?.slice(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJob(job: ClientJob) {
|
||||||
|
console.log(`\n=== ${job.slug} (${job.brand}) ===`);
|
||||||
|
const tenantId = await resolveTenantId(job.slug);
|
||||||
|
const wanted = new Set(Object.keys(job.sheets));
|
||||||
|
if (job.slug === 'bps') wanted.add('Calendario');
|
||||||
|
const sheets = loadSheets(job.file, wanted);
|
||||||
|
const byMonth = new Map<string, RrssPiece[]>();
|
||||||
|
const prefix = job.slug.replace(/[^a-z0-9]+/g, '').slice(0, 12) || 'cal';
|
||||||
|
|
||||||
|
for (const sh of sheets) {
|
||||||
|
const ym = job.sheets[sh.name];
|
||||||
|
if (!ym) continue;
|
||||||
|
if (!job.onlyMonths.includes(ym)) continue;
|
||||||
|
const [year, month] = ym.split('-').map(Number);
|
||||||
|
const pieces = parseWeekGrid(
|
||||||
|
prefix,
|
||||||
|
sh.rows as SheetRows,
|
||||||
|
year!,
|
||||||
|
month!
|
||||||
|
);
|
||||||
|
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
|
||||||
|
console.log(` parsed ${sh.name} → ${ym} (${pieces.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BPS structured tab fills may–jun 2025 etc. if missing density
|
||||||
|
if (job.slug === 'bps') {
|
||||||
|
const cal = sheets.find((s) => s.name === 'Calendario');
|
||||||
|
if (cal) {
|
||||||
|
const structured = parseStructuredCalendario(
|
||||||
|
prefix,
|
||||||
|
cal.rows as SheetRows
|
||||||
|
);
|
||||||
|
for (const [ym, pieces] of structured) {
|
||||||
|
if (!job.onlyMonths.includes(ym)) continue;
|
||||||
|
const cur = byMonth.get(ym) || [];
|
||||||
|
// Prefer structured when grid empty/sparse (<5)
|
||||||
|
if (cur.length < 5) {
|
||||||
|
byMonth.set(ym, mergePieces(pieces, cur));
|
||||||
|
console.log(
|
||||||
|
` calendario fill → ${ym} grid=${cur.length} +struct=${pieces.length}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const blogs = pieces.filter((p) => p.platform === 'Blog');
|
||||||
|
if (blogs.length) {
|
||||||
|
byMonth.set(ym, mergePieces(cur, blogs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ym of job.onlyMonths) {
|
||||||
|
const pieces = byMonth.get(ym) || [];
|
||||||
|
if (!pieces.length) {
|
||||||
|
console.log(` SKIP ${ym} (0 pieces in source)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await upsertPlan(tenantId, buildPlan(job.brand, ym, pieces, job.file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const job of JOBS) {
|
||||||
|
await runJob(job);
|
||||||
|
}
|
||||||
|
console.log('\nDone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
569
scripts/import_drive_calendars_live.py
Normal file
569
scripts/import_drive_calendars_live.py
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Import RRSS calendars from live Google Drive sheets → documents (type=calendar).
|
||||||
|
|
||||||
|
python3 scripts/import_drive_calendars_live.py
|
||||||
|
|
||||||
|
Exports each client's CALENDARIO spreadsheet, maps tabs → YYYY-MM via weekday
|
||||||
|
alignment, parses SEMANA×STORIES/POST/LINKEDIN grids, upserts missing/thin months.
|
||||||
|
Preserves richer existing plans (e.g. Agua Miia Oct 2026 bundled) when they have
|
||||||
|
more pieces than the Drive grid parse.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ENV = ROOT / ".env.local"
|
||||||
|
OUT = Path("/tmp/drive-cals")
|
||||||
|
|
||||||
|
DRIVE_SHEETS = {
|
||||||
|
"agua-miia": {
|
||||||
|
"brand": "Agua Miia",
|
||||||
|
"file_id": "18Dl4ln6uPkoSHK2CABdjYIQYbB870gsxdHtgGDKW8Tw",
|
||||||
|
},
|
||||||
|
"bps": {
|
||||||
|
"brand": "Blue Parrot School",
|
||||||
|
"file_id": "1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s",
|
||||||
|
},
|
||||||
|
"brava-dermoestetica": {
|
||||||
|
"brand": "Brava Dermoestética",
|
||||||
|
"file_id": "1blboVr4Ig3xb_YQjO84_I6_yDuT0MjkdKYWjbeNY4dc",
|
||||||
|
},
|
||||||
|
"halal-safety": {
|
||||||
|
"brand": "Halal Safety",
|
||||||
|
"file_id": "1SOZQMFFRulLeafd2ojCjBSykvPKRA6npJvOyp3b0_r4",
|
||||||
|
},
|
||||||
|
"noberplast": {
|
||||||
|
"brand": "Noberplast",
|
||||||
|
"file_id": "1GhDxBOem20Fv7nFWfJKN445S1ixe_7W4_f1WeGsRPdc",
|
||||||
|
# Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero).
|
||||||
|
"force_sheet_months": {"Hoja 1": "2026-10"},
|
||||||
|
},
|
||||||
|
"tyd": {
|
||||||
|
"brand": "Talento y Deporte",
|
||||||
|
"file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA",
|
||||||
|
},
|
||||||
|
"4ventures-hub": {
|
||||||
|
"brand": "4Ventures",
|
||||||
|
"file_id": "1OJVh2FJzXI5-yRm9LB6InbnteUAHxqOT7u6Ch2tEv7Y",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHS = {
|
||||||
|
"ENERO": 1,
|
||||||
|
"FEBRERO": 2,
|
||||||
|
"MARZO": 3,
|
||||||
|
"ABRIL": 4,
|
||||||
|
"MAYO": 5,
|
||||||
|
"JUNIO": 6,
|
||||||
|
"JULIO": 7,
|
||||||
|
"AGOSTO": 8,
|
||||||
|
"SEPTIEMBRE": 9,
|
||||||
|
"SEPTIMEBRE": 9,
|
||||||
|
"OCTUBRE": 10,
|
||||||
|
"NOVIEMBRE": 11,
|
||||||
|
"DICIEMBRE": 12,
|
||||||
|
}
|
||||||
|
WD = {
|
||||||
|
"LUNES": 0,
|
||||||
|
"MARTES": 1,
|
||||||
|
"MIERCOLES": 2,
|
||||||
|
"JUEVES": 3,
|
||||||
|
"JUVES": 3,
|
||||||
|
"VIERNES": 4,
|
||||||
|
"SABADO": 5,
|
||||||
|
"DOMINGO": 6,
|
||||||
|
}
|
||||||
|
SKIP_TABS = re.compile(
|
||||||
|
r"ideas|calendario$|publicidad|tareas|competidores|mayo-diciembre",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
# "Hoja N" only skipped when we cannot infer a month; single-sheet clients (Noberplast) keep them.
|
||||||
|
MONTH_LABELS = [
|
||||||
|
"",
|
||||||
|
"Enero",
|
||||||
|
"Febrero",
|
||||||
|
"Marzo",
|
||||||
|
"Abril",
|
||||||
|
"Mayo",
|
||||||
|
"Junio",
|
||||||
|
"Julio",
|
||||||
|
"Agosto",
|
||||||
|
"Septiembre",
|
||||||
|
"Octubre",
|
||||||
|
"Noviembre",
|
||||||
|
"Diciembre",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_env() -> dict[str, str]:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for line in ENV.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
env[k] = v.strip().strip('"').strip("'")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def rest(env: dict[str, str], path: str, method: str = "GET", body: Any = None):
|
||||||
|
url = (env.get("NEXT_PUBLIC_SUPABASE_URL") or env.get("SUPABASE_URL") or "").rstrip(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
|
key = env["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
|
headers = {
|
||||||
|
"apikey": key,
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "return=representation",
|
||||||
|
}
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url + path, data=data, headers=headers, method=method)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
raw = r.read().decode()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
def drive_token(env: dict[str, str]) -> str:
|
||||||
|
oauth = rest(
|
||||||
|
env, "/rest/v1/agency_oauth?provider=eq.google_drive&select=refresh_token"
|
||||||
|
)[0]
|
||||||
|
cid = env.get("GOOGLE_OAUTH_CLIENT_ID") or env.get("NEXT_PUBLIC_GOOGLE_CLIENT_ID")
|
||||||
|
csec = env.get("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"client_id": cid,
|
||||||
|
"client_secret": csec,
|
||||||
|
"refresh_token": oauth["refresh_token"],
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def export_xlsx(token: str, file_id: str, dest: Path) -> Path:
|
||||||
|
export_url = (
|
||||||
|
f"https://www.googleapis.com/drive/v3/files/{file_id}/export?"
|
||||||
|
+ urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"mimeType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
export_url, headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=180) as r:
|
||||||
|
dest.write_bytes(r.read())
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(s: str) -> str:
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
s = unicodedata.normalize("NFD", s.lower())
|
||||||
|
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||||
|
return s[:48]
|
||||||
|
|
||||||
|
|
||||||
|
def infer_month_num(name: str) -> int | None:
|
||||||
|
u = name.upper().replace("SEPTIMEBRE", "SEPTIEMBRE")
|
||||||
|
for m, n in MONTHS.items():
|
||||||
|
if m in u:
|
||||||
|
return n
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_wd(name: str) -> str:
|
||||||
|
return (
|
||||||
|
name.upper()
|
||||||
|
.replace("É", "E")
|
||||||
|
.replace("Á", "A")
|
||||||
|
.replace("Í", "I")
|
||||||
|
.replace("Ó", "O")
|
||||||
|
.replace("Ú", "U")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_headers(ws) -> list[tuple[str, int]]:
|
||||||
|
hits: list[tuple[str, int]] = []
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=50, max_col=12, values_only=True):
|
||||||
|
for v in row or []:
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = normalize_wd(str(v).strip())
|
||||||
|
m = re.match(
|
||||||
|
r"(LUNES|MARTES|MIERCOLES|JUEVES|JUVES|VIERNES|SABADO|DOMINGO)\s+(\d{1,2})\s*$",
|
||||||
|
s,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
hits.append((m.group(1), int(m.group(2))))
|
||||||
|
return hits[:20]
|
||||||
|
|
||||||
|
|
||||||
|
def best_year(month: int, hits: list[tuple[str, int]], tab_name: str) -> int | None:
|
||||||
|
# Explicit year in tab wins when present
|
||||||
|
ym = re.search(r"(20\d{2})", tab_name)
|
||||||
|
if ym:
|
||||||
|
return int(ym.group(1))
|
||||||
|
if re.search(r"\b26\b", tab_name) or tab_name.upper().endswith("26"):
|
||||||
|
return 2026
|
||||||
|
|
||||||
|
scores: dict[int, tuple[int, int]] = {}
|
||||||
|
for y in (2024, 2025, 2026, 2027):
|
||||||
|
ok = tot = 0
|
||||||
|
for wdname, day in hits:
|
||||||
|
if day < 1 or day > 31:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
d = date(y, month, day)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
want = WD.get(wdname)
|
||||||
|
if want is None:
|
||||||
|
continue
|
||||||
|
tot += 1
|
||||||
|
if d.weekday() == want:
|
||||||
|
ok += 1
|
||||||
|
scores[y] = (ok, tot)
|
||||||
|
if not any(v[1] for v in scores.values()):
|
||||||
|
return None
|
||||||
|
best = max(scores.items(), key=lambda kv: (kv[1][0], -abs(kv[0] - 2026)))
|
||||||
|
if best[1][0] == 0:
|
||||||
|
return None
|
||||||
|
return best[0]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_day_header(h: str) -> int | None:
|
||||||
|
raw = str(h or "").strip()
|
||||||
|
if not raw or re.match(r"^x\b", raw, re.I) or re.match(r"^columna", raw, re.I):
|
||||||
|
return None
|
||||||
|
m = re.search(r"(\d{1,2})\s*$", raw)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
n = int(m.group(1))
|
||||||
|
return n if 1 <= n <= 31 else None
|
||||||
|
|
||||||
|
|
||||||
|
def detect_kind(c0: str) -> str | None:
|
||||||
|
u = c0.upper()
|
||||||
|
if u.startswith("STORIES") or u == "STORY" or u.startswith("STORY "):
|
||||||
|
return "STORIES"
|
||||||
|
if u.startswith("LINKEDIN"):
|
||||||
|
return "LINKEDIN"
|
||||||
|
if u.startswith("POST") or u.startswith("REEL") or u.startswith("CARRUSEL"):
|
||||||
|
return "POST"
|
||||||
|
if u == "STORY" or u.startswith("HISTORIA"):
|
||||||
|
return "STORIES"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def piece_meta(kind: str) -> dict[str, str]:
|
||||||
|
if kind == "STORIES":
|
||||||
|
return {
|
||||||
|
"platform": "instagram",
|
||||||
|
"format": "Stories",
|
||||||
|
"pillar": "stories",
|
||||||
|
"kind": "story",
|
||||||
|
"color": "#D9821F",
|
||||||
|
"suffix": "st",
|
||||||
|
}
|
||||||
|
if kind == "LINKEDIN":
|
||||||
|
return {
|
||||||
|
"platform": "linkedin",
|
||||||
|
"format": "Post",
|
||||||
|
"pillar": "linkedin",
|
||||||
|
"kind": "post",
|
||||||
|
"color": "#7C66B8",
|
||||||
|
"suffix": "li",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"platform": "instagram",
|
||||||
|
"format": "Post",
|
||||||
|
"pillar": "feed",
|
||||||
|
"kind": "post",
|
||||||
|
"color": "#2071cf",
|
||||||
|
"suffix": "post",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_week_grid(prefix: str, rows: list[list[Any]], year: int, month: int) -> list[dict]:
|
||||||
|
pieces: list[dict] = []
|
||||||
|
day_cols: list[tuple[int, int]] = []
|
||||||
|
kind: str | None = None
|
||||||
|
week_wraps = False
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if not row or not any(c is not None and str(c).strip() for c in row):
|
||||||
|
continue
|
||||||
|
c0 = str(row[0] or "").strip()
|
||||||
|
c0u = c0.upper()
|
||||||
|
|
||||||
|
if c0u.startswith("SEMANA") or re.match(
|
||||||
|
r"^(LUNES|MARTES|MI[EÉ]RCOLES)", str(row[1] if len(row) > 1 else "")
|
||||||
|
):
|
||||||
|
day_cols = []
|
||||||
|
for col in range(1, min(len(row), 12)):
|
||||||
|
day = parse_day_header(str(row[col] or ""))
|
||||||
|
if day:
|
||||||
|
day_cols.append((col, day))
|
||||||
|
week_wraps = any(
|
||||||
|
i > 0
|
||||||
|
and day_cols[i][1] < day_cols[i - 1][1]
|
||||||
|
and day_cols[i - 1][1] >= 28
|
||||||
|
and day_cols[i][1] <= 7
|
||||||
|
for i in range(len(day_cols))
|
||||||
|
)
|
||||||
|
kind = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
re.match(r"^(PUBLICADO|CLIENTE|APROBADO)$", c0u)
|
||||||
|
or c0u.startswith("GUIONES")
|
||||||
|
or c0u.startswith("CARPETA")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
detected = detect_kind(c0u)
|
||||||
|
if detected:
|
||||||
|
kind = detected
|
||||||
|
elif not kind:
|
||||||
|
continue
|
||||||
|
if not day_cols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta = piece_meta(kind)
|
||||||
|
for col, day in day_cols:
|
||||||
|
if col >= len(row):
|
||||||
|
continue
|
||||||
|
raw = row[col]
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
text = str(raw).strip()
|
||||||
|
if (
|
||||||
|
not text
|
||||||
|
or text == "X"
|
||||||
|
or re.match(r"^columna", text, re.I)
|
||||||
|
or re.match(r"^(PUBLICADO|APROBADO)$", text, re.I)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
y, m = year, month
|
||||||
|
if week_wraps and day >= 28:
|
||||||
|
m = month - 1
|
||||||
|
if m < 1:
|
||||||
|
m = 12
|
||||||
|
y = year - 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = date(y, m, day)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
date_s = dt.isoformat()
|
||||||
|
title = text.split("\n")[0][:120]
|
||||||
|
pid = f"{prefix}-{date_s}-{meta['suffix']}-{slugify(title)[:24]}"
|
||||||
|
pieces.append(
|
||||||
|
{
|
||||||
|
"id": pid,
|
||||||
|
"date": date_s,
|
||||||
|
"platform": meta["platform"],
|
||||||
|
"format": meta["format"],
|
||||||
|
"pillar": meta["pillar"],
|
||||||
|
"title": title,
|
||||||
|
"body": text,
|
||||||
|
"status": "planned",
|
||||||
|
"kind": meta["kind"],
|
||||||
|
"color": meta["color"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
out = []
|
||||||
|
for p in pieces:
|
||||||
|
if p["id"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(p["id"])
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_rows(ws) -> list[list[Any]]:
|
||||||
|
rows = []
|
||||||
|
for row in ws.iter_rows(values_only=True):
|
||||||
|
rows.append(list(row))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(brand: str, ym: str, pieces: list[dict], source: str) -> dict:
|
||||||
|
y, m = map(int, ym.split("-"))
|
||||||
|
label = f"{MONTH_LABELS[m]} {y}"
|
||||||
|
posts = sum(1 for p in pieces if p.get("kind") != "story")
|
||||||
|
stories = sum(1 for p in pieces if p.get("kind") == "story")
|
||||||
|
pieces = sorted(pieces, key=lambda p: p["date"])
|
||||||
|
return {
|
||||||
|
"kind": "rrss_month",
|
||||||
|
"month": ym,
|
||||||
|
"label": label,
|
||||||
|
"hero": {
|
||||||
|
"eyebrow": f"4VENTURES × {brand.upper()} · PLAN RRSS",
|
||||||
|
"title": f"Calendario editorial · {label}",
|
||||||
|
"lede": f"Importado desde Drive ({source}).",
|
||||||
|
"chips": [f"{posts} posts", f"{stories} stories", "Fuente: Google Drive"],
|
||||||
|
},
|
||||||
|
"pieces": pieces,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def existing_plans(env: dict[str, str], tenant_id: str) -> dict[str, dict]:
|
||||||
|
docs = rest(
|
||||||
|
env,
|
||||||
|
f"/rest/v1/documents?tenant_id=eq.{tenant_id}&type=eq.calendar&select=id,title,content",
|
||||||
|
)
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for d in docs or []:
|
||||||
|
c = d.get("content")
|
||||||
|
if isinstance(c, str):
|
||||||
|
try:
|
||||||
|
c = json.loads(c)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(c, dict) and c.get("kind") == "rrss_month" and c.get("month"):
|
||||||
|
out[str(c["month"])] = {
|
||||||
|
"id": d["id"],
|
||||||
|
"title": d.get("title"),
|
||||||
|
"pieces": len(c.get("pieces") or []),
|
||||||
|
"content": c,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_plan(env: dict[str, str], tenant_id: str, plan: dict) -> str:
|
||||||
|
title = f"RRSS {plan['label']}"
|
||||||
|
docs = rest(
|
||||||
|
env,
|
||||||
|
f"/rest/v1/documents?tenant_id=eq.{tenant_id}&type=eq.calendar&select=id,title,content",
|
||||||
|
)
|
||||||
|
for d in docs or []:
|
||||||
|
c = d.get("content")
|
||||||
|
if isinstance(c, str):
|
||||||
|
try:
|
||||||
|
c = json.loads(c)
|
||||||
|
except Exception:
|
||||||
|
c = None
|
||||||
|
month = c.get("month") if isinstance(c, dict) else None
|
||||||
|
same_title = d.get("title") == title or (
|
||||||
|
plan["label"].lower() in (d.get("title") or "").lower()
|
||||||
|
)
|
||||||
|
if month == plan["month"] or same_title:
|
||||||
|
rest(env, f"/rest/v1/documents?id=eq.{d['id']}", method="DELETE")
|
||||||
|
saved = rest(
|
||||||
|
env,
|
||||||
|
"/rest/v1/documents",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"type": "calendar",
|
||||||
|
"title": title,
|
||||||
|
"content": plan,
|
||||||
|
"status": "approved",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (saved or [{}])[0].get("id", "")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tenant(env: dict[str, str], slug: str) -> str:
|
||||||
|
rows = rest(
|
||||||
|
env, f"/rest/v1/tenants?slug=eq.{urllib.parse.quote(slug)}&select=id&limit=1"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit(f"tenant not found: {slug}")
|
||||||
|
return rows[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def import_client(env: dict[str, str], token: str, slug: str, meta: dict) -> None:
|
||||||
|
brand = meta["brand"]
|
||||||
|
print(f"\n=== {slug} ({brand}) ===")
|
||||||
|
dest = OUT / f"{slug}.xlsx"
|
||||||
|
if not dest.exists() or dest.stat().st_size < 1000:
|
||||||
|
print(" exporting from Drive…")
|
||||||
|
export_xlsx(token, meta["file_id"], dest)
|
||||||
|
else:
|
||||||
|
print(f" using cached {dest}")
|
||||||
|
|
||||||
|
wb = load_workbook(dest, data_only=True)
|
||||||
|
tenant_id = resolve_tenant(env, slug)
|
||||||
|
existing = existing_plans(env, tenant_id)
|
||||||
|
prefix = re.sub(r"[^a-z0-9]+", "", slug)[:12] or "cal"
|
||||||
|
|
||||||
|
force_map = meta.get("force_sheet_months") or {}
|
||||||
|
for name in wb.sheetnames:
|
||||||
|
if SKIP_TABS.search(name or ""):
|
||||||
|
print(f" skip tab {name}")
|
||||||
|
continue
|
||||||
|
if re.match(r"^hoja\s*\d+$", name or "", re.I) and len(wb.sheetnames) > 1 and name not in force_map:
|
||||||
|
print(f" skip tab {name}")
|
||||||
|
continue
|
||||||
|
hits = sample_headers(wb[name])
|
||||||
|
if name in force_map:
|
||||||
|
ym = force_map[name]
|
||||||
|
year, month_n = map(int, ym.split("-"))
|
||||||
|
print(f" {name} (forced) → {ym}")
|
||||||
|
else:
|
||||||
|
month_n = infer_month_num(name)
|
||||||
|
if not month_n:
|
||||||
|
print(f" skip tab {name} (no month)")
|
||||||
|
continue
|
||||||
|
year = best_year(month_n, hits, name)
|
||||||
|
if not year:
|
||||||
|
print(f" skip tab {name} (cannot infer year)")
|
||||||
|
continue
|
||||||
|
ym = f"{year}-{month_n:02d}"
|
||||||
|
pieces = parse_week_grid(prefix, sheet_rows(wb[name]), year, month_n)
|
||||||
|
print(f" {name} → {ym} ({len(pieces)} pieces)")
|
||||||
|
if not pieces:
|
||||||
|
print(" SKIP empty")
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev = existing.get(ym)
|
||||||
|
if prev and prev["pieces"] >= len(pieces) and prev["pieces"] >= 8:
|
||||||
|
# Keep richer portal plan (e.g. Agua Miia Oct operative JSON).
|
||||||
|
print(
|
||||||
|
f" KEEP existing ({prev['pieces']} pieces ≥ Drive {len(pieces)})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plan = build_plan(brand, ym, pieces, dest.name)
|
||||||
|
doc_id = upsert_plan(env, tenant_id, plan)
|
||||||
|
print(f" UPSERT {ym} → {doc_id[:8] if doc_id else '?'} ({len(pieces)} pieces)")
|
||||||
|
existing[ym] = {"id": doc_id, "title": plan["label"], "pieces": len(pieces), "content": plan}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = load_env()
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
token = drive_token(env)
|
||||||
|
for slug, meta in DRIVE_SHEETS.items():
|
||||||
|
import_client(env, token, slug, meta)
|
||||||
|
print("\nDone.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
53
scripts/list_bps_calendar_sheets.ts
Normal file
53
scripts/list_bps_calendar_sheets.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
|
||||||
|
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
mkdirSync('/tmp/bps-cal', { recursive: true });
|
||||||
|
for (const sid of [ID, OCT]) {
|
||||||
|
const r = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sid}?fields=properties.title,sheets.properties`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
const j = (await r.json()) as {
|
||||||
|
properties?: { title?: string };
|
||||||
|
sheets?: { properties?: { title?: string; sheetId?: number } }[];
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
|
console.log('status', r.status, JSON.stringify(j).slice(0, 500));
|
||||||
|
console.log('\n===', j.properties?.title, '===');
|
||||||
|
for (const s of j.sheets || []) {
|
||||||
|
const title = s.properties?.title || '';
|
||||||
|
console.log(' sheet:', title);
|
||||||
|
const csv = await fetch(
|
||||||
|
`https://docs.google.com/spreadsheets/d/${sid}/export?format=csv&gid=${s.properties?.sheetId ?? 0}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' }
|
||||||
|
);
|
||||||
|
// Sheets export via docs URL may need alternate - use values API
|
||||||
|
const vals = await fetch(
|
||||||
|
`https://sheets.googleapis.com/v4/spreadsheets/${sid}/values/${encodeURIComponent(title)}!A1:Z80`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
const vj = (await vals.json()) as { values?: string[][] };
|
||||||
|
const rows = vj.values || [];
|
||||||
|
const safe = title.replace(/[^\w.-]+/g, '_');
|
||||||
|
writeFileSync(
|
||||||
|
`/tmp/bps-cal/${sid.slice(0, 6)}_${safe}.json`,
|
||||||
|
JSON.stringify(rows, null, 2)
|
||||||
|
);
|
||||||
|
console.log(' rows', rows.length, 'head0', (rows[0] || []).slice(0, 8).join(' | '));
|
||||||
|
console.log(' head1', (rows[1] || []).slice(0, 8).join(' | '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
64
scripts/list_drive_calendars.ts
Normal file
64
scripts/list_drive_calendars.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/** List Drive calendar folder and find BPS files. */
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const FOLDER = '12tPM0_nuVEf9MBUeyipmcWAJ-gFFgqXh';
|
||||||
|
|
||||||
|
async function listChildren(token: string, parentId: string) {
|
||||||
|
const out: { id: string; name: string; mimeType: string }[] = [];
|
||||||
|
let pageToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: `'${parentId}' in parents and trashed=false`,
|
||||||
|
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime)',
|
||||||
|
pageSize: '100',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
});
|
||||||
|
if (pageToken) params.set('pageToken', pageToken);
|
||||||
|
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const j = (await r.json()) as {
|
||||||
|
files?: { id: string; name: string; mimeType: string }[];
|
||||||
|
nextPageToken?: string;
|
||||||
|
};
|
||||||
|
if (!r.ok) throw new Error(JSON.stringify(j).slice(0, 400));
|
||||||
|
out.push(...(j.files || []));
|
||||||
|
pageToken = j.nextPageToken;
|
||||||
|
} while (pageToken);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
console.log('token ok');
|
||||||
|
const top = await listChildren(token, FOLDER);
|
||||||
|
console.log('top count', top.length);
|
||||||
|
for (const f of top.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const kind = f.mimeType.includes('folder') ? 'DIR' : 'FILE';
|
||||||
|
console.log(`${kind}\t${f.name}\t${f.id}`);
|
||||||
|
}
|
||||||
|
const bps = top.filter((f) => /bps|blue.?parrot|parrot/i.test(f.name));
|
||||||
|
console.log('\nBPS matches', bps.length);
|
||||||
|
for (const f of bps) {
|
||||||
|
if (f.mimeType.includes('folder')) {
|
||||||
|
const kids = await listChildren(token, f.id);
|
||||||
|
console.log(`\n--- ${f.name} (${kids.length}) ---`);
|
||||||
|
for (const k of kids.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
console.log(
|
||||||
|
` ${k.mimeType.includes('folder') ? 'DIR' : 'FILE'}\t${k.name}\t${k.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
101
scripts/probe_bps_seguimiento_drive.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
import {
|
||||||
|
buildSeguimientoRowsForDoc,
|
||||||
|
upsertSeguimientoRows,
|
||||||
|
patchSuperhumanIntegration,
|
||||||
|
findSeguimientoGoogleDoc,
|
||||||
|
exportGoogleDocHtml,
|
||||||
|
splitHtmlNotesByDay,
|
||||||
|
} from '../src/lib/superhuman-docs';
|
||||||
|
|
||||||
|
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const DOC = '7TGywaNiUY';
|
||||||
|
|
||||||
|
async function driveSearch(q: string) {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q,
|
||||||
|
fields: 'files(id,name,mimeType,webViewLink,modifiedTime)',
|
||||||
|
pageSize: '30',
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: 'true',
|
||||||
|
supportsAllDrives: 'true',
|
||||||
|
});
|
||||||
|
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return ((await r.json()) as { files?: unknown[] }).files || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(
|
||||||
|
'search seguimiento+parrot',
|
||||||
|
await driveSearch(
|
||||||
|
"name contains 'Seguimiento' and name contains 'Parrot' and trashed=false"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'search seguimiento+BPS',
|
||||||
|
await driveSearch(
|
||||||
|
"name contains 'Seguimiento' and name contains 'BPS' and trashed=false"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'search Blue Parrot docs',
|
||||||
|
await driveSearch(
|
||||||
|
"name contains 'Blue Parrot' and mimeType='application/vnd.google-apps.document' and trashed=false"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'findSeguimiento',
|
||||||
|
await findSeguimientoGoogleDoc([
|
||||||
|
'Seguimiento Semanal Blue Parrot School',
|
||||||
|
'Seguimiento Semanal BPS',
|
||||||
|
'Seguimiento Semanal Blue Parrot',
|
||||||
|
'Blue Parrot School',
|
||||||
|
'BPS',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-run full build (includes Drive embed discovery from Superhuman pages)
|
||||||
|
const rows = await buildSeguimientoRowsForDoc({
|
||||||
|
tenantId: BPS,
|
||||||
|
tenantName: 'Blue Parrot School',
|
||||||
|
docId: DOC,
|
||||||
|
maxEntries: 120,
|
||||||
|
exportBodies: true,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
'rows',
|
||||||
|
rows.length,
|
||||||
|
rows.slice(0, 8).map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
occurred: r.occurred_at,
|
||||||
|
ext: r.external_id,
|
||||||
|
bodyLen: (r.body_md || '').length,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const n = await upsertSeguimientoRows(rows);
|
||||||
|
await patchSuperhumanIntegration(BPS, {
|
||||||
|
status: 'connected',
|
||||||
|
external_account_id: DOC,
|
||||||
|
label: '01.BLUE PARROT SCHOOL',
|
||||||
|
last_sync_at: new Date().toISOString(),
|
||||||
|
error_message: null,
|
||||||
|
meta: {
|
||||||
|
doc_name: '01.BLUE PARROT SCHOOL',
|
||||||
|
entries: n,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('upserted', n);
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
52
scripts/probe_bps_superhuman.ts
Normal file
52
scripts/probe_bps_superhuman.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import {
|
||||||
|
listDocs,
|
||||||
|
bestDocForTenant,
|
||||||
|
cleanClientDocName,
|
||||||
|
} from '../src/lib/superhuman-docs';
|
||||||
|
import { scoreNameToTenant } from '../src/lib/match';
|
||||||
|
import { findSeguimientoGoogleDoc } from '../src/lib/superhuman-docs';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const docs = await listDocs();
|
||||||
|
console.log('docs', docs.length);
|
||||||
|
for (const d of docs) {
|
||||||
|
const clean = cleanClientDocName(d.name);
|
||||||
|
const s1 = scoreNameToTenant(clean, 'BPS', 'bps');
|
||||||
|
const s2 = scoreNameToTenant(clean, 'Blue Parrot School', 'blue-parrot');
|
||||||
|
if (
|
||||||
|
s1 >= 15 ||
|
||||||
|
s2 >= 15 ||
|
||||||
|
/parrot|bps|blue/i.test(d.name)
|
||||||
|
) {
|
||||||
|
console.log({ name: d.name, clean, s1, s2, id: d.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'best BPS',
|
||||||
|
bestDocForTenant({ name: 'BPS', slug: 'bps' }, docs)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'best Blue Parrot School',
|
||||||
|
bestDocForTenant(
|
||||||
|
{ name: 'Blue Parrot School', slug: 'blue-parrot-school' },
|
||||||
|
docs
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const drive = await findSeguimientoGoogleDoc([
|
||||||
|
'Seguimiento Semanal BPS',
|
||||||
|
'Seguimiento Semanal Blue Parrot School',
|
||||||
|
'Seguimiento Semanal Blue Parrot',
|
||||||
|
'BPS',
|
||||||
|
'Blue Parrot School',
|
||||||
|
]);
|
||||||
|
console.log('drive hit', drive);
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
41
scripts/probe_seguimiento_html.ts
Normal file
41
scripts/probe_seguimiento_html.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import {
|
||||||
|
exportGoogleDocHtml,
|
||||||
|
splitHtmlNotesByDay,
|
||||||
|
splitNotesByDay,
|
||||||
|
} from '../src/lib/superhuman-docs';
|
||||||
|
|
||||||
|
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const html = await exportGoogleDocHtml(FILE);
|
||||||
|
console.log('html len', html.length);
|
||||||
|
console.log('sample', html.slice(0, 1500));
|
||||||
|
// find date-like patterns
|
||||||
|
const dates = html.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/g) || [];
|
||||||
|
console.log('date-like', [...new Set(dates)].slice(0, 40));
|
||||||
|
const headings = html.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || [];
|
||||||
|
console.log(
|
||||||
|
'headings',
|
||||||
|
headings.slice(0, 20).map((h) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
|
||||||
|
);
|
||||||
|
const secs = splitHtmlNotesByDay(html);
|
||||||
|
console.log('html sections', secs.length, secs.slice(0, 3).map((s) => s.label));
|
||||||
|
const plain = html
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, '\n');
|
||||||
|
const plainSecs = splitNotesByDay(plain);
|
||||||
|
console.log(
|
||||||
|
'plain sections',
|
||||||
|
plainSecs.length,
|
||||||
|
plainSecs.slice(0, 5).map((s) => s.label)
|
||||||
|
);
|
||||||
|
console.log('plain sample\n', plain.slice(0, 2000));
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
136
scripts/provision_agency_admins.ts
Normal file
136
scripts/provision_agency_admins.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Provision agency admin accounts (role=admin, tenant=4Ventures Central).
|
||||||
|
* npx tsx scripts/provision_agency_admins.ts
|
||||||
|
*
|
||||||
|
* Writes credentials to .ops/agency-admins-credentials.txt (gitignored).
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
||||||
|
const COMPANY = '4Ventures';
|
||||||
|
const SLUG = '4ventures-hub';
|
||||||
|
|
||||||
|
const EMAILS = [
|
||||||
|
'marcos@4ventures.es',
|
||||||
|
'belen@4ventures.es',
|
||||||
|
'rebecca@4ventures.es',
|
||||||
|
'victor@4ventures.es',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function tempPassword(local: string): string {
|
||||||
|
const tag = local.split('@')[0].replace(/[^a-z]/gi, '').slice(0, 8) || 'User';
|
||||||
|
const cap = tag.charAt(0).toUpperCase() + tag.slice(1).toLowerCase();
|
||||||
|
return `4V${cap}-${randomBytes(4).toString('hex')}!9`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findUser(
|
||||||
|
admin: ReturnType<typeof createClient>,
|
||||||
|
email: string
|
||||||
|
) {
|
||||||
|
for (let page = 1; page <= 40; page++) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({
|
||||||
|
page,
|
||||||
|
perPage: 200,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
const hit = data.users.find(
|
||||||
|
(u) => (u.email || '').toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
if (hit) return hit;
|
||||||
|
if (data.users.length < 200) break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) {
|
||||||
|
throw new Error(
|
||||||
|
'Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createClient(url, key, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
'# Agency admin access — app.4ventures.es/login',
|
||||||
|
`# generated ${new Date().toISOString()}`,
|
||||||
|
`# tenant ${COMPANY} (${HUB_TENANT}) role=admin`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const email of EMAILS) {
|
||||||
|
const password = tempPassword(email);
|
||||||
|
const app_metadata = {
|
||||||
|
role: 'admin',
|
||||||
|
tenant_id: HUB_TENANT,
|
||||||
|
company: COMPANY,
|
||||||
|
slug: SLUG,
|
||||||
|
};
|
||||||
|
const user_metadata = {
|
||||||
|
role: 'admin',
|
||||||
|
tenant_id: HUB_TENANT,
|
||||||
|
company: COMPANY,
|
||||||
|
slug: SLUG,
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = await findUser(admin, email);
|
||||||
|
if (found) {
|
||||||
|
const { error } = await admin.auth.admin.updateUserById(found.id, {
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata: { ...(found.app_metadata || {}), ...app_metadata },
|
||||||
|
user_metadata: { ...(found.user_metadata || {}), ...user_metadata },
|
||||||
|
});
|
||||||
|
if (error) throw new Error(`${email}: ${error.message}`);
|
||||||
|
console.log('updated', email, found.id);
|
||||||
|
lines.push(`email=${email}`);
|
||||||
|
lines.push(`password=${password}`);
|
||||||
|
lines.push(`user_id=${found.id}`);
|
||||||
|
lines.push(`action=updated`);
|
||||||
|
lines.push('');
|
||||||
|
} else {
|
||||||
|
const { data, error } = await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata,
|
||||||
|
user_metadata,
|
||||||
|
});
|
||||||
|
if (error) throw new Error(`${email}: ${error.message}`);
|
||||||
|
console.log('created', email, data.user?.id);
|
||||||
|
lines.push(`email=${email}`);
|
||||||
|
lines.push(`password=${password}`);
|
||||||
|
lines.push(`user_id=${data.user?.id || ''}`);
|
||||||
|
lines.push(`action=created`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(resolve(process.cwd(), '.ops'), { recursive: true });
|
||||||
|
const out = resolve(process.cwd(), '.ops/agency-admins-credentials.txt');
|
||||||
|
writeFileSync(out, lines.join('\n'), { mode: 0o600 });
|
||||||
|
console.log('---');
|
||||||
|
console.log('credentials', out);
|
||||||
|
console.log('login https://app.4ventures.es/login');
|
||||||
|
for (const email of EMAILS) {
|
||||||
|
const pass = lines.find(
|
||||||
|
(l, i) => lines[i - 1] === `email=${email}` && l.startsWith('password=')
|
||||||
|
);
|
||||||
|
console.log(email, pass?.replace('password=', '') || '?');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
96
scripts/provision_victor_bps.ts
Normal file
96
scripts/provision_victor_bps.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Provision victor@4ventures.es as BPS client portal user.
|
||||||
|
* npx tsx scripts/provision_victor_bps.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const EMAIL = 'victor@4ventures.es';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
|
||||||
|
|
||||||
|
const admin = createClient(url, key, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = `BpsVictor-${randomBytes(4).toString('hex')}!9`;
|
||||||
|
|
||||||
|
let found: { id: string; app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> } | null =
|
||||||
|
null;
|
||||||
|
for (let page = 1; page <= 30; page++) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 200 });
|
||||||
|
if (error) throw error;
|
||||||
|
const hit = data.users.find((u) => (u.email || '').toLowerCase() === EMAIL);
|
||||||
|
if (hit) {
|
||||||
|
found = hit;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (data.users.length < 200) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app_metadata = {
|
||||||
|
...(found?.app_metadata || {}),
|
||||||
|
role: 'client',
|
||||||
|
tenant_id: BPS,
|
||||||
|
company: 'Blue Parrot School',
|
||||||
|
};
|
||||||
|
const user_metadata = {
|
||||||
|
...(found?.user_metadata || {}),
|
||||||
|
role: 'client',
|
||||||
|
tenant_id: BPS,
|
||||||
|
company: 'Blue Parrot School',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
const { error } = await admin.auth.admin.updateUserById(found.id, {
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata,
|
||||||
|
user_metadata,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
console.log('updated', found.id);
|
||||||
|
} else {
|
||||||
|
const { data, error } = await admin.auth.admin.createUser({
|
||||||
|
email: EMAIL,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata,
|
||||||
|
user_metadata,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
console.log('created', data.user?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
resolve(process.cwd(), '.ops/victor-bps-credentials.txt'),
|
||||||
|
[
|
||||||
|
`email=${EMAIL}`,
|
||||||
|
`password=${password}`,
|
||||||
|
`tenant=Blue Parrot School (${BPS})`,
|
||||||
|
`role=client`,
|
||||||
|
`updated_at=${new Date().toISOString()}`,
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
{ mode: 0o600 }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('---');
|
||||||
|
console.log('url https://app.4ventures.es/login');
|
||||||
|
console.log('email', EMAIL);
|
||||||
|
console.log('password', password);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
592
scripts/refresh_agency_connections.py
Normal file
592
scripts/refresh_agency_connections.py
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Refresh Meta + Google agency matches into client fichas and audit."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
META = "https://graph.facebook.com/v21.0"
|
||||||
|
GADS = "https://googleads.googleapis.com/v18"
|
||||||
|
|
||||||
|
|
||||||
|
def load_env() -> dict[str, str]:
|
||||||
|
vals: dict[str, str] = {}
|
||||||
|
for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
for line in p.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip() or line.strip().startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
vals.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
ENV = load_env()
|
||||||
|
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
|
||||||
|
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
|
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||||
|
|
||||||
|
|
||||||
|
def sb_get(path: str):
|
||||||
|
req = urllib.request.Request(BASE + path, headers=SH)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def sb_patch(path: str, body: dict) -> bool:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + path,
|
||||||
|
data=data,
|
||||||
|
headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"},
|
||||||
|
method="PATCH",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return 200 <= r.status < 300
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool:
|
||||||
|
path = (
|
||||||
|
f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}"
|
||||||
|
f"&provider=eq.{urllib.parse.quote(provider)}"
|
||||||
|
)
|
||||||
|
body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()}
|
||||||
|
if sb_patch(path, body):
|
||||||
|
return True
|
||||||
|
data = json.dumps(
|
||||||
|
{"tenant_id": tenant_id, "provider": provider, "enabled": True, **body}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
BASE + "/rest/v1/integrations",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
**SH,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Prefer": "resolution=merge-duplicates,return=minimal",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as _:
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print("upsert fail", provider, e.read().decode()[:200])
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s: str) -> str:
|
||||||
|
s = s.lower()
|
||||||
|
s = (
|
||||||
|
s.replace("á", "a")
|
||||||
|
.replace("é", "e")
|
||||||
|
.replace("í", "i")
|
||||||
|
.replace("ó", "o")
|
||||||
|
.replace("ú", "u")
|
||||||
|
.replace("ñ", "n")
|
||||||
|
)
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", s).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
|
||||||
|
t = norm(tenant_name)
|
||||||
|
s = norm(tenant_slug.replace("-", " "))
|
||||||
|
a = norm(account_name)
|
||||||
|
if not a:
|
||||||
|
return 0
|
||||||
|
if a == t or a == s:
|
||||||
|
return 100
|
||||||
|
if t and (t in a or a in t):
|
||||||
|
return 85
|
||||||
|
if s and (s in a or a in s):
|
||||||
|
return 80
|
||||||
|
tt = set(x for x in t.split() if len(x) > 2)
|
||||||
|
aa = set(x for x in a.split() if len(x) > 2)
|
||||||
|
if not tt or not aa:
|
||||||
|
return 0
|
||||||
|
hit = len(tt & aa) / len(tt)
|
||||||
|
if hit >= 0.8:
|
||||||
|
return 70
|
||||||
|
if hit >= 0.5:
|
||||||
|
return 55
|
||||||
|
return int(hit * 50)
|
||||||
|
|
||||||
|
|
||||||
|
def best_matches(tenants, accounts, threshold=55):
|
||||||
|
"""Greedy 1:1 match like portal."""
|
||||||
|
scored = []
|
||||||
|
for t in tenants:
|
||||||
|
for acc in accounts:
|
||||||
|
sc = score_name(t["name"], t["slug"], acc["name"])
|
||||||
|
if sc >= threshold:
|
||||||
|
scored.append((sc, t["id"], acc))
|
||||||
|
scored.sort(key=lambda x: -x[0])
|
||||||
|
used_t, used_a = set(), set()
|
||||||
|
out = {}
|
||||||
|
for sc, tid, acc in scored:
|
||||||
|
if tid in used_t or acc["id"] in used_a:
|
||||||
|
continue
|
||||||
|
used_t.add(tid)
|
||||||
|
used_a.add(acc["id"])
|
||||||
|
out[tid] = (acc, sc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def meta_get(token: str, path: str):
|
||||||
|
url = f"{META}/{path}"
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token))
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def meta_paginate(token: str, path: str):
|
||||||
|
out = []
|
||||||
|
url = f"{META}/{path}"
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
url = url + sep + "access_token=" + urllib.parse.quote(token)
|
||||||
|
while url:
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
out.extend(j.get("data") or [])
|
||||||
|
url = (j.get("paging") or {}).get("next")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def google_refresh(refresh: str) -> str:
|
||||||
|
body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
|
||||||
|
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def gads_access() -> str:
|
||||||
|
refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN")
|
||||||
|
cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID")
|
||||||
|
secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET")
|
||||||
|
body = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"client_id": cid,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://oauth2.googleapis.com/token",
|
||||||
|
data=body,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.loads(r.read().decode())["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc")
|
||||||
|
print(f"tenants={len(tenants)}")
|
||||||
|
|
||||||
|
# --- tokens ---
|
||||||
|
g_rows = sb_get(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes"
|
||||||
|
)
|
||||||
|
m_rows = sb_get(
|
||||||
|
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at"
|
||||||
|
)
|
||||||
|
if not g_rows:
|
||||||
|
print("FAIL: no google_drive oauth")
|
||||||
|
return 1
|
||||||
|
if not m_rows:
|
||||||
|
print("FAIL: no meta_ads oauth")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
grow = g_rows[0]
|
||||||
|
mrow = m_rows[0]
|
||||||
|
print("Google:", grow.get("account_email"))
|
||||||
|
print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at"))
|
||||||
|
|
||||||
|
gtoken = grow["access_token"]
|
||||||
|
exp = grow.get("expires_at")
|
||||||
|
if exp:
|
||||||
|
try:
|
||||||
|
et = datetime.fromisoformat(exp.replace("Z", "+00:00"))
|
||||||
|
if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60:
|
||||||
|
print("Refreshing Google access token…")
|
||||||
|
gtoken = google_refresh(grow["refresh_token"])
|
||||||
|
except Exception as e:
|
||||||
|
print("google refresh warn", e)
|
||||||
|
gtoken = google_refresh(grow["refresh_token"])
|
||||||
|
|
||||||
|
mtoken = mrow["access_token"]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
linked_counts = defaultdict(int)
|
||||||
|
|
||||||
|
# ========== META ==========
|
||||||
|
print("\n=== META Ads + Pages + IG ===")
|
||||||
|
ad_accounts = []
|
||||||
|
for row in meta_paginate(
|
||||||
|
mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200"
|
||||||
|
):
|
||||||
|
aid = row.get("account_id") or row["id"].replace("act_", "")
|
||||||
|
ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid})
|
||||||
|
print(f"Meta ad accounts visible: {len(ad_accounts)}")
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
igs = []
|
||||||
|
for page in meta_paginate(
|
||||||
|
mtoken,
|
||||||
|
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
|
||||||
|
):
|
||||||
|
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
|
||||||
|
ig = page.get("instagram_business_account") or {}
|
||||||
|
if ig.get("id"):
|
||||||
|
igs.append(
|
||||||
|
{
|
||||||
|
"id": ig["id"],
|
||||||
|
"name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"FB pages visible: {len(pages)} · IG: {len(igs)}")
|
||||||
|
|
||||||
|
for provider, accounts, label in (
|
||||||
|
("meta_ads", ad_accounts, "Meta Ads"),
|
||||||
|
("facebook", pages, "Facebook Page"),
|
||||||
|
("instagram", igs, "Instagram"),
|
||||||
|
):
|
||||||
|
matches = best_matches(tenants, accounts, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
ok = sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
provider,
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"enabled": True,
|
||||||
|
"meta": {
|
||||||
|
"account_name": acc["name"],
|
||||||
|
"match_score": sc,
|
||||||
|
"matched_at": now,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
linked_counts[provider] += 1
|
||||||
|
print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}")
|
||||||
|
|
||||||
|
# ========== GOOGLE DRIVE ==========
|
||||||
|
print("\n=== Google Drive folders ===")
|
||||||
|
folders = []
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"q": "mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||||
|
"fields": "nextPageToken,files(id,name)",
|
||||||
|
"pageSize": "1000",
|
||||||
|
"corpora": "allDrives",
|
||||||
|
"includeItemsFromAllDrives": "true",
|
||||||
|
"supportsAllDrives": "true",
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params),
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
for f in j.get("files") or []:
|
||||||
|
folders.append({"id": f["id"], "name": f["name"]})
|
||||||
|
page_token = j.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
print(f"Drive folders: {len(folders)}")
|
||||||
|
matches = best_matches(tenants, folders, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"google_drive",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["google_drive"] += 1
|
||||||
|
print(f" linked google_drive: {linked_counts['google_drive']}")
|
||||||
|
|
||||||
|
# ========== GA4 ==========
|
||||||
|
print("\n=== GA4 properties ===")
|
||||||
|
ga4 = []
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
summaries = json.loads(r.read().decode())
|
||||||
|
for acc in summaries.get("accountSummaries") or []:
|
||||||
|
for p in acc.get("propertySummaries") or []:
|
||||||
|
prop = p.get("property")
|
||||||
|
if not prop:
|
||||||
|
continue
|
||||||
|
# measurement id
|
||||||
|
mid = None
|
||||||
|
try:
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req2, timeout=60) as r2:
|
||||||
|
streams = json.loads(r2.read().decode())
|
||||||
|
for s in streams.get("dataStreams") or []:
|
||||||
|
mid = (s.get("webStreamData") or {}).get("measurementId")
|
||||||
|
if mid:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ga4.append(
|
||||||
|
{
|
||||||
|
"id": mid or prop,
|
||||||
|
"name": p.get("displayName") or prop,
|
||||||
|
"property_id": prop,
|
||||||
|
"measurement_id": mid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(f"GA4 properties: {len(ga4)}")
|
||||||
|
matches = best_matches(tenants, ga4, threshold=50)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"ga4",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {
|
||||||
|
"property_id": acc.get("property_id"),
|
||||||
|
"measurement_id": acc.get("measurement_id"),
|
||||||
|
"display_name": acc["name"],
|
||||||
|
"match_score": sc,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["ga4"] += 1
|
||||||
|
print(f" linked ga4: {linked_counts['ga4']}")
|
||||||
|
|
||||||
|
# ========== GSC ==========
|
||||||
|
print("\n=== Search Console ===")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://www.googleapis.com/webmasters/v3/sites",
|
||||||
|
headers={"Authorization": f"Bearer {gtoken}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
sites_j = json.loads(r.read().decode())
|
||||||
|
gsc = []
|
||||||
|
for s in sites_j.get("siteEntry") or []:
|
||||||
|
url = s.get("siteUrl")
|
||||||
|
if url:
|
||||||
|
gsc.append({"id": url, "name": url})
|
||||||
|
print(f"GSC sites: {len(gsc)}")
|
||||||
|
matches = best_matches(tenants, gsc, threshold=40)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"search_console",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["id"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["gsc"] += 1
|
||||||
|
print(f" linked gsc: {linked_counts['gsc']}")
|
||||||
|
|
||||||
|
# ========== Google Ads ==========
|
||||||
|
print("\n=== Google Ads ===")
|
||||||
|
try:
|
||||||
|
at = gads_access()
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {at}",
|
||||||
|
"developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "")
|
||||||
|
if login:
|
||||||
|
headers["login-customer-id"] = login
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GADS}/customers:listAccessibleCustomers", headers=headers
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=90) as r:
|
||||||
|
listed = json.loads(r.read().decode())
|
||||||
|
ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []]
|
||||||
|
gads_accounts = []
|
||||||
|
for cid in ids:
|
||||||
|
h = dict(headers)
|
||||||
|
h["login-customer-id"] = cid
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GADS}/customers/{cid}/googleAds:search",
|
||||||
|
data=body,
|
||||||
|
headers=h,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
j = json.loads(r.read().decode())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
c = ((j.get("results") or [{}])[0]).get("customer") or {}
|
||||||
|
if not c:
|
||||||
|
continue
|
||||||
|
name = c.get("descriptiveName") or cid
|
||||||
|
if c.get("manager"):
|
||||||
|
# pull clients
|
||||||
|
body2 = json.dumps(
|
||||||
|
{
|
||||||
|
"query": (
|
||||||
|
"SELECT customer_client.id, customer_client.descriptive_name, "
|
||||||
|
"customer_client.manager, customer_client.status FROM customer_client "
|
||||||
|
"WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"{GADS}/customers/{cid}/googleAds:search",
|
||||||
|
data=body2,
|
||||||
|
headers=h,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req2, timeout=90) as r2:
|
||||||
|
j2 = json.loads(r2.read().decode())
|
||||||
|
for row in j2.get("results") or []:
|
||||||
|
cc = row.get("customerClient") or {}
|
||||||
|
if cc.get("id"):
|
||||||
|
gads_accounts.append(
|
||||||
|
{
|
||||||
|
"id": str(cc["id"]),
|
||||||
|
"name": cc.get("descriptiveName") or str(cc["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
gads_accounts.append({"id": str(c.get("id") or cid), "name": name})
|
||||||
|
# dedupe
|
||||||
|
mmap = {a["id"]: a for a in gads_accounts}
|
||||||
|
gads_accounts = list(mmap.values())
|
||||||
|
print(f"Google Ads accounts: {len(gads_accounts)}")
|
||||||
|
matches = best_matches(tenants, gads_accounts, threshold=55)
|
||||||
|
for tid, (acc, sc) in matches.items():
|
||||||
|
if sb_upsert_integ(
|
||||||
|
tid,
|
||||||
|
"google_ads",
|
||||||
|
{
|
||||||
|
"status": "connected",
|
||||||
|
"external_account_id": acc["id"],
|
||||||
|
"label": acc["name"],
|
||||||
|
"last_sync_at": now,
|
||||||
|
"error_message": None,
|
||||||
|
"meta": {
|
||||||
|
"account_name": acc["name"],
|
||||||
|
"customer_id": acc["id"],
|
||||||
|
"match_score": sc,
|
||||||
|
"source": "refresh_script",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
linked_counts["google_ads"] += 1
|
||||||
|
print(f" linked google_ads: {linked_counts['google_ads']}")
|
||||||
|
except Exception as e:
|
||||||
|
print("Google Ads ERROR:", e)
|
||||||
|
|
||||||
|
# ========== AUDIT ==========
|
||||||
|
print("\n=== AUDIT ===")
|
||||||
|
integs = sb_get(
|
||||||
|
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
|
||||||
|
"&select=tenant_id,provider,status,external_account_id,error_message"
|
||||||
|
)
|
||||||
|
by = defaultdict(dict)
|
||||||
|
for i in integs:
|
||||||
|
by[i["tenant_id"]][i["provider"]] = i
|
||||||
|
|
||||||
|
def flag(i):
|
||||||
|
if not i:
|
||||||
|
return "—"
|
||||||
|
if i.get("status") == "connected" and i.get("external_account_id"):
|
||||||
|
return "OK"
|
||||||
|
if i.get("status") == "error":
|
||||||
|
return "ERR"
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org"
|
||||||
|
)
|
||||||
|
for t in tenants:
|
||||||
|
m = by[t["id"]]
|
||||||
|
# organic count
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day",
|
||||||
|
headers={**SH, "Prefer": "count=exact", "Range": "0-0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
cr = r.headers.get("content-range") or "*/0"
|
||||||
|
org = cr.split("/")[-1]
|
||||||
|
print(
|
||||||
|
f"{t['name'][:30]:30} "
|
||||||
|
f"{flag(m.get('google_ads')):5} "
|
||||||
|
f"{flag(m.get('meta_ads')):5} "
|
||||||
|
f"{flag(m.get('ga4')):5} "
|
||||||
|
f"{flag(m.get('search_console')):5} "
|
||||||
|
f"{flag(m.get('google_drive')):5} "
|
||||||
|
f"{flag(m.get('facebook')):5} "
|
||||||
|
f"{flag(m.get('instagram')):5} "
|
||||||
|
f"{org}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n=== Linked counts this run ===")
|
||||||
|
print(dict(linked_counts))
|
||||||
|
print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
203
scripts/refresh_agency_connections.ts
Normal file
203
scripts/refresh_agency_connections.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* One-shot: bootstrap Google + discover Meta → fichas, then print audit.
|
||||||
|
* Usage: npx tsx --env-file=.env.local scripts/refresh_agency_connections.ts
|
||||||
|
*/
|
||||||
|
import { bootstrapAgencyGoogle } from '../src/lib/agency-google-bootstrap';
|
||||||
|
import { discoverAndMatch } from '../src/lib/ads-discovery';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||||
|
|
||||||
|
async function get(path: string) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) throw new Error('no service role');
|
||||||
|
const r = await fetch(`${supabaseUrl()}${path}`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${path}: ${await r.text()}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function organicCount(tenantId: string): Promise<number> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) return 0;
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${tenantId}&select=day`,
|
||||||
|
{
|
||||||
|
headers: { ...headers, Prefer: 'count=exact', Range: '0-0' },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const cr = r.headers.get('content-range') || '';
|
||||||
|
if (cr.includes('/')) return Number(cr.split('/').pop()) || 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== 1. Google bootstrap (Drive + GA4 + GSC + Ads + sync) ===');
|
||||||
|
const boot = await bootstrapAgencyGoogle({
|
||||||
|
includeGoogleAds: true,
|
||||||
|
syncOrganic: true,
|
||||||
|
organicDays: 28,
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(boot, null, 2));
|
||||||
|
|
||||||
|
console.log('\n=== 2. Meta discover (Ads + FB + IG) ===');
|
||||||
|
const meta = await discoverAndMatch({
|
||||||
|
apply: true,
|
||||||
|
providers: ['meta_ads', 'google_ads'],
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
accounts: meta.meta?.accounts,
|
||||||
|
linked: meta.meta?.linked,
|
||||||
|
pagesLinked: meta.meta?.pagesLinked,
|
||||||
|
igLinked: meta.meta?.igLinked,
|
||||||
|
error: meta.meta?.error,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
accounts: meta.google?.accounts,
|
||||||
|
linked: meta.google?.linked,
|
||||||
|
error: meta.google?.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n=== 3. Audit por cliente ===');
|
||||||
|
const tenants = (await get(
|
||||||
|
'/rest/v1/tenants?select=id,name,slug&order=name.asc'
|
||||||
|
)) as { id: string; name: string; slug: string }[];
|
||||||
|
const integs = (await get(
|
||||||
|
'/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)&select=tenant_id,provider,status,external_account_id,error_message,meta'
|
||||||
|
)) as {
|
||||||
|
tenant_id: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
external_account_id?: string | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
meta?: Record<string, unknown> | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const by = new Map<string, typeof integs>();
|
||||||
|
for (const i of integs) {
|
||||||
|
const arr = by.get(i.tenant_id) || [];
|
||||||
|
arr.push(i);
|
||||||
|
by.set(i.tenant_id, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
'google_ads',
|
||||||
|
'meta_ads',
|
||||||
|
'ga4',
|
||||||
|
'search_console',
|
||||||
|
'google_drive',
|
||||||
|
'facebook',
|
||||||
|
'instagram',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
name: string;
|
||||||
|
organic_days: number;
|
||||||
|
channels: Record<string, string>;
|
||||||
|
gaps: string[];
|
||||||
|
};
|
||||||
|
const rows: Row[] = [];
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
const map = new Map(
|
||||||
|
(by.get(t.id) || []).map((i) => [i.provider, i] as const)
|
||||||
|
);
|
||||||
|
const channels: Record<string, string> = {};
|
||||||
|
const gaps: string[] = [];
|
||||||
|
for (const p of providers) {
|
||||||
|
const i = map.get(p);
|
||||||
|
if (i?.status === 'connected' && i.external_account_id) {
|
||||||
|
const id = String(i.external_account_id);
|
||||||
|
channels[p] = id.length > 42 ? id.slice(0, 40) + '…' : id;
|
||||||
|
} else if (i?.status === 'error') {
|
||||||
|
channels[p] = `ERR:${(i.error_message || '').slice(0, 40)}`;
|
||||||
|
gaps.push(p);
|
||||||
|
} else {
|
||||||
|
channels[p] = '—';
|
||||||
|
gaps.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const org = await organicCount(t.id);
|
||||||
|
rows.push({ name: t.name, organic_days: org, channels, gaps });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
'Cliente'.padEnd(28),
|
||||||
|
'GAds',
|
||||||
|
'Meta',
|
||||||
|
'GA4',
|
||||||
|
'GSC',
|
||||||
|
'Drive',
|
||||||
|
'FB',
|
||||||
|
'IG',
|
||||||
|
'OrgDías',
|
||||||
|
].join('\t')
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
const c = r.channels;
|
||||||
|
const flag = (v: string) => (v === '—' ? '—' : v.startsWith('ERR') ? 'ERR' : 'OK');
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
r.name.slice(0, 28).padEnd(28),
|
||||||
|
flag(c.google_ads),
|
||||||
|
flag(c.meta_ads),
|
||||||
|
flag(c.ga4),
|
||||||
|
flag(c.search_console),
|
||||||
|
flag(c.google_drive),
|
||||||
|
flag(c.facebook),
|
||||||
|
flag(c.instagram),
|
||||||
|
String(r.organic_days),
|
||||||
|
].join('\t')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Detalle IDs (solo conectados) ===');
|
||||||
|
for (const r of rows) {
|
||||||
|
const linked = Object.entries(r.channels).filter(([, v]) => v !== '—' && !v.startsWith('ERR'));
|
||||||
|
if (!linked.length && r.organic_days === 0) continue;
|
||||||
|
console.log(`\n${r.name} · organic_daily=${r.organic_days}`);
|
||||||
|
for (const [p, v] of linked) console.log(` ${p}: ${v}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withAds = rows.filter(
|
||||||
|
(r) => r.channels.google_ads !== '—' || r.channels.meta_ads !== '—'
|
||||||
|
).length;
|
||||||
|
const withOrg = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.channels.ga4 !== '—' ||
|
||||||
|
r.channels.search_console !== '—' ||
|
||||||
|
r.organic_days > 0
|
||||||
|
).length;
|
||||||
|
const withSocial = rows.filter(
|
||||||
|
(r) => r.channels.facebook !== '—' || r.channels.instagram !== '—'
|
||||||
|
).length;
|
||||||
|
console.log('\n=== RESUMEN ===');
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
tenants: rows.length,
|
||||||
|
with_ads: withAds,
|
||||||
|
with_organic_link_or_data: withOrg,
|
||||||
|
with_fb_or_ig: withSocial,
|
||||||
|
organic_tenants_with_rows: rows.filter((r) => r.organic_days > 0).length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
64
scripts/regen_bps_nov_preview.ts
Normal file
64
scripts/regen_bps_nov_preview.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
import { generateMonthPreview } from '../src/lib/calendar-generate';
|
||||||
|
import type { RrssMonthPlan } from '../src/components/RrssMonthView';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = { apikey: key, Authorization: `Bearer ${key}` };
|
||||||
|
const tid = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
|
||||||
|
const r = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const docs = (await r.json()) as { id: string; content: unknown }[];
|
||||||
|
const history: RrssMonthPlan[] = [];
|
||||||
|
let novId: string | null = null;
|
||||||
|
for (const d of docs) {
|
||||||
|
const c =
|
||||||
|
typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
|
||||||
|
const plan = c as RrssMonthPlan;
|
||||||
|
if (plan?.month === '2026-11') novId = d.id;
|
||||||
|
else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateMonthPreview({
|
||||||
|
ym: '2026-11',
|
||||||
|
slug: 'bps',
|
||||||
|
label: 'Blue Parrot School',
|
||||||
|
history,
|
||||||
|
});
|
||||||
|
console.log('source', result.source);
|
||||||
|
console.log('rationale\n', result.rationale);
|
||||||
|
console.log('pieces', result.plan.pieces.length);
|
||||||
|
for (const p of result.plan.pieces) {
|
||||||
|
console.log(p.date, p.format.padEnd(8), '|', p.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!novId) {
|
||||||
|
console.log('No Nov doc to patch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...h,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: result.plan,
|
||||||
|
title: 'RRSS Noviembre 2026',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log('patched', patch.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
632
scripts/remediate_brand_truth.py
Normal file
632
scripts/remediate_brand_truth.py
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Remedia brandbooks: elimina kits inventados y aplica solo señales
|
||||||
|
verificadas desde webs oficiales (+ notas de procedencia).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def env(k: str) -> str:
|
||||||
|
for line in (ROOT / ".env.local").read_text().splitlines():
|
||||||
|
if line.startswith(k + "="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
URL = env("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
KEY = env("SUPABASE_SERVICE_ROLE_KEY")
|
||||||
|
HR = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
|
||||||
|
HW = {**HR, "Content-Type": "application/json", "Prefer": "return=minimal"}
|
||||||
|
|
||||||
|
|
||||||
|
def get(path: str) -> Any:
|
||||||
|
req = urllib.request.Request(f"{URL}/rest/v1/{path}", headers=HR)
|
||||||
|
return json.loads(urllib.request.urlopen(req).read())
|
||||||
|
|
||||||
|
|
||||||
|
def patch(path: str, body: dict) -> None:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{URL}/rest/v1/{path}",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers=HW,
|
||||||
|
method="PATCH",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
|
||||||
|
|
||||||
|
def nid(p: str) -> str:
|
||||||
|
return f"{p}_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
|
||||||
|
def color(name: str, hex_: str, role: str, usage: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": nid("col"),
|
||||||
|
"name": name,
|
||||||
|
"hex": hex_.upper() if hex_.startswith("#") else hex_,
|
||||||
|
"role": role,
|
||||||
|
"usage": usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def font(family: str, role: str, source: str, notes: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": nid("font"),
|
||||||
|
"family": family,
|
||||||
|
"role": role,
|
||||||
|
"weights": "",
|
||||||
|
"source": source,
|
||||||
|
"url_or_notes": notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Solo colores con evidencia fuerte en HTML/CSS de la web oficial.
|
||||||
|
# Si no hay evidencia clara → colors = [] (mejor vacío que inventado).
|
||||||
|
VERIFIED_VISUAL: dict[str, dict[str, Any]] = {
|
||||||
|
"4ventures-hub": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Naranja marca (web)",
|
||||||
|
"#E05200",
|
||||||
|
"primary",
|
||||||
|
"Frecuencia alta en CSS de https://4ventures.es/",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"DM Sans",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"font-family detectado en HTML de 4ventures.es",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Cormorant Garamond",
|
||||||
|
"display",
|
||||||
|
"google",
|
||||||
|
"font-family detectado en HTML de 4ventures.es",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://4ventures.es/",
|
||||||
|
},
|
||||||
|
"agua-miia": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Azul primario",
|
||||||
|
"#0085F2",
|
||||||
|
"primary",
|
||||||
|
"CSS var marca en https://aguamiia.es/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul corporativo",
|
||||||
|
"#2071CF",
|
||||||
|
"secondary",
|
||||||
|
"CSS var / uso frecuente en aguamiia.es",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul oscuro",
|
||||||
|
"#0F4F9E",
|
||||||
|
"neutral",
|
||||||
|
"CSS var en aguamiia.es",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Verde acento",
|
||||||
|
"#2E9B76",
|
||||||
|
"accent",
|
||||||
|
"CSS var en aguamiia.es",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Urbanist",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"font-family dominante en HTML de aguamiia.es",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Nunito",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family secundario en HTML de aguamiia.es",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "CSS variables + font-family https://aguamiia.es/",
|
||||||
|
},
|
||||||
|
"bps": {
|
||||||
|
# BPS ya tenía kit; reforzamos con theme-color oficial
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"BPS Blue",
|
||||||
|
"#0051AA",
|
||||||
|
"primary",
|
||||||
|
"theme-color meta + uso en https://blueparrotschool.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Accent amarillo",
|
||||||
|
"#FDC526",
|
||||||
|
"accent",
|
||||||
|
"Hex frecuente en CSS de blueparrotschool.com",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Ink",
|
||||||
|
"#201E22",
|
||||||
|
"neutral",
|
||||||
|
"Hex frecuente en CSS de blueparrotschool.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Figtree",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"Referenciado en HTML blueparrotschool.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Jost",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"Referenciado en HTML blueparrotschool.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "theme-color + CSS https://blueparrotschool.com/",
|
||||||
|
"merge_colors_if_existing": True,
|
||||||
|
},
|
||||||
|
"brava-dermoestetica": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Rosa/brandy clínica",
|
||||||
|
"#7A3F4D",
|
||||||
|
"primary",
|
||||||
|
"Hex dominante (69×) en https://www.bravadermoestetica.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Rosa oscuro",
|
||||||
|
"#61323D",
|
||||||
|
"secondary",
|
||||||
|
"Hex frecuente en bravadermoestetica.com",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Fondo cálido",
|
||||||
|
"#F8F5F2",
|
||||||
|
"neutral",
|
||||||
|
"Hex de fondo en bravadermoestetica.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Playfair Display",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"font-family en HTML bravadermoestetica.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Inter",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family en HTML bravadermoestetica.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Myriad Pro",
|
||||||
|
"other",
|
||||||
|
"adobe",
|
||||||
|
"font-family referenciado en HTML (licencia Adobe/sistema)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://www.bravadermoestetica.com/",
|
||||||
|
},
|
||||||
|
"delgado-abogados": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Azul enlace/marca",
|
||||||
|
"#1863DC",
|
||||||
|
"primary",
|
||||||
|
"Hex frecuente en https://abogadosdelgado.es/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Ink",
|
||||||
|
"#212121",
|
||||||
|
"neutral",
|
||||||
|
"Hex frecuente en abogadosdelgado.es",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Inter",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"WP preset font-family Inter en abogadosdelgado.es",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://abogadosdelgado.es/ (parcial; ampliar con brand kit PDF)",
|
||||||
|
},
|
||||||
|
"dtatf": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Amarillo De Tú a Tú",
|
||||||
|
"#F6A800",
|
||||||
|
"primary",
|
||||||
|
"Hex en CSS/HTML https://detuatuformacion.es/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul oscuro De Tú a Tú",
|
||||||
|
"#273645",
|
||||||
|
"secondary",
|
||||||
|
"Hex en CSS/HTML detuatuformacion.es (+ #253645)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Poppins-DeTUaTU",
|
||||||
|
"headline",
|
||||||
|
"file",
|
||||||
|
"Familia custom referenciada en detuatuformacion.es (archivo/fuente propia)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://detuatuformacion.es/",
|
||||||
|
},
|
||||||
|
"ecp": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Amarillo ECP",
|
||||||
|
"#F7A801",
|
||||||
|
"primary",
|
||||||
|
"Hex dominante en https://escueladecuidadoresprofesionales.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul",
|
||||||
|
"#1863DC",
|
||||||
|
"secondary",
|
||||||
|
"Hex frecuente en web ECP",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Ink",
|
||||||
|
"#212121",
|
||||||
|
"neutral",
|
||||||
|
"Hex frecuente en web ECP",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Raleway",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"font-family dominante en web ECP",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Open Sans",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family secundario en web ECP",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://escueladecuidadoresprofesionales.com/",
|
||||||
|
},
|
||||||
|
"equinsa-parking": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Navy Equinsa",
|
||||||
|
"#000028",
|
||||||
|
"primary",
|
||||||
|
"Hex en https://equinsaparking.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul",
|
||||||
|
"#003388",
|
||||||
|
"secondary",
|
||||||
|
"Hex en equinsaparking.com",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul enlace",
|
||||||
|
"#046BD2",
|
||||||
|
"accent",
|
||||||
|
"CSS var / hex en equinsaparking.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Roboto",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family dominante en equinsaparking.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://equinsaparking.com/",
|
||||||
|
},
|
||||||
|
"fbm-abogados": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Navy FBM",
|
||||||
|
"#04243C",
|
||||||
|
"primary",
|
||||||
|
"Hex dominante (75×) en https://fbmasociados.com/",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [], # tahoma genérico — no afirmar kit de marca
|
||||||
|
"source": "HTML/CSS https://fbmasociados.com/ (color). Tipografía: pendiente brand kit.",
|
||||||
|
},
|
||||||
|
"halal-safety": {
|
||||||
|
"colors": [], # scrape débil / ruido WP
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Manrope",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"Referenciado en HTML halalsafety.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "Tipografía HTML https://www.halalsafety.com/. Colores: insuficientes en scrape — vacíos a propósito.",
|
||||||
|
},
|
||||||
|
"la-freseria": {
|
||||||
|
"colors": [], # el scrape pilló azules de template Framer, NO son marca fresa
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"La Fresería",
|
||||||
|
"display",
|
||||||
|
"file",
|
||||||
|
"Familia custom 'La Fresería' referenciada en lafreseria.es (archivo propio)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "Fuentes custom en https://lafreseria.es/. Colores NO aplicados: el HTML mezcla tokens Framer (#046bd2) no confiables como marca.",
|
||||||
|
},
|
||||||
|
"noberplast": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Azul Noberplast",
|
||||||
|
"#0072AB",
|
||||||
|
"primary",
|
||||||
|
"Hex en https://noberplast.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Verde",
|
||||||
|
"#06A161",
|
||||||
|
"secondary",
|
||||||
|
"Hex en noberplast.com",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Rojo acento",
|
||||||
|
"#DB062C",
|
||||||
|
"accent",
|
||||||
|
"Hex en noberplast.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [],
|
||||||
|
"source": "Hex en https://noberplast.com/. Tipografía: no detectada con claridad.",
|
||||||
|
},
|
||||||
|
"tsp": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Navy Scholar",
|
||||||
|
"#161B2F",
|
||||||
|
"primary",
|
||||||
|
"Hex frecuente en https://talentoscholarprogram.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul claro",
|
||||||
|
"#A4CAEF",
|
||||||
|
"secondary",
|
||||||
|
"Hex frecuente en talentoscholarprogram.com",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Rojo acento",
|
||||||
|
"#A40406",
|
||||||
|
"accent",
|
||||||
|
"Hex en talentoscholarprogram.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Poppins",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family dominante en talentoscholarprogram.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Fraunces",
|
||||||
|
"display",
|
||||||
|
"google",
|
||||||
|
"font-family en talentoscholarprogram.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://talentoscholarprogram.com/",
|
||||||
|
},
|
||||||
|
"tyd": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Rojo TYD",
|
||||||
|
"#E92428",
|
||||||
|
"primary",
|
||||||
|
"Hex dominante en https://talentoydeporte.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul TYD",
|
||||||
|
"#004885",
|
||||||
|
"secondary",
|
||||||
|
"Hex en talentoydeporte.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"Montserrat",
|
||||||
|
"headline",
|
||||||
|
"google",
|
||||||
|
"font-family en talentoydeporte.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Oswald",
|
||||||
|
"display",
|
||||||
|
"google",
|
||||||
|
"font-family referenciado en talentoydeporte.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://talentoydeporte.com/",
|
||||||
|
},
|
||||||
|
"virginia-molet": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Azul Gravitae",
|
||||||
|
"#004271",
|
||||||
|
"primary",
|
||||||
|
"Hex frecuente en https://gravitaeacademy.com/",
|
||||||
|
),
|
||||||
|
color(
|
||||||
|
"Azul enlace",
|
||||||
|
"#1863DC",
|
||||||
|
"secondary",
|
||||||
|
"Hex frecuente en gravitaeacademy.com (posible token WP; contrastar con kit)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [
|
||||||
|
font(
|
||||||
|
"quincy-cf",
|
||||||
|
"display",
|
||||||
|
"adobe",
|
||||||
|
"font-family 'quincy-cf' en gravitaeacademy.com",
|
||||||
|
),
|
||||||
|
font(
|
||||||
|
"Inter",
|
||||||
|
"body",
|
||||||
|
"google",
|
||||||
|
"font-family Inter en gravitaeacademy.com",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"source": "HTML/CSS https://gravitaeacademy.com/ (revisar azul #1863DC vs kit oficial)",
|
||||||
|
},
|
||||||
|
"wine-advisors": {
|
||||||
|
"colors": [
|
||||||
|
color(
|
||||||
|
"Rojo WineAdvisor",
|
||||||
|
"#CC0100",
|
||||||
|
"primary",
|
||||||
|
"Hex detectado en https://www.wineadvisor.mx/",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"fonts_kit": [],
|
||||||
|
"source": "Hex limitado en https://www.wineadvisor.mx/. Tipografía: no detectada.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
PROVENANCE_HEADER = (
|
||||||
|
"[PROCEDENCIA 2026-10-15] "
|
||||||
|
"Webs/handles/LinkedIn: contrastados con sitio oficial + Meta business_discovery + páginas LinkedIn. "
|
||||||
|
"Colores/fuentes: solo si aparecen en HTML/CSS de la web (ver visual_notes). "
|
||||||
|
"Textos de posicionamiento/tono/ICP: síntesis de brand manager a partir del copy público de la web "
|
||||||
|
"(no son citas literales del cliente ni de un PDF de marca). "
|
||||||
|
"Si un campo no tiene evidencia → vacío. "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(c: Any) -> dict:
|
||||||
|
if isinstance(c, str):
|
||||||
|
return json.loads(c)
|
||||||
|
return dict(c or {})
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
tenants = get(
|
||||||
|
"tenants?agency_followed=neq.false&select=id,slug,name&order=slug"
|
||||||
|
)
|
||||||
|
for t in tenants:
|
||||||
|
slug = t["slug"]
|
||||||
|
docs = get(
|
||||||
|
f"documents?tenant_id=eq.{t['id']}&type=eq.brandbook&select=id,content&order=updated_at.desc&limit=1"
|
||||||
|
)
|
||||||
|
if not docs:
|
||||||
|
print("skip", slug)
|
||||||
|
continue
|
||||||
|
book = parse(docs[0]["content"])
|
||||||
|
vis = VERIFIED_VISUAL.get(slug)
|
||||||
|
|
||||||
|
if vis is None:
|
||||||
|
# no verified visual → clear invented kits
|
||||||
|
book["colors"] = []
|
||||||
|
book["fonts_kit"] = []
|
||||||
|
book["colors_primary"] = []
|
||||||
|
book["colors_secondary"] = []
|
||||||
|
book["fonts"] = []
|
||||||
|
note = "Sin señales visuales verificables en scrape; kit vaciado."
|
||||||
|
else:
|
||||||
|
book["colors"] = vis.get("colors") or []
|
||||||
|
book["fonts_kit"] = vis.get("fonts_kit") or []
|
||||||
|
book["colors_primary"] = [
|
||||||
|
c["hex"] for c in book["colors"] if c.get("role") == "primary"
|
||||||
|
]
|
||||||
|
book["colors_secondary"] = [
|
||||||
|
c["hex"]
|
||||||
|
for c in book["colors"]
|
||||||
|
if c.get("role") in ("secondary", "accent")
|
||||||
|
]
|
||||||
|
book["fonts"] = [f["family"] for f in book["fonts_kit"]]
|
||||||
|
if book["fonts_kit"]:
|
||||||
|
heads = [f for f in book["fonts_kit"] if f["role"] in ("headline", "display")]
|
||||||
|
bodies = [f for f in book["fonts_kit"] if f["role"] == "body"]
|
||||||
|
if heads:
|
||||||
|
book["typography_headline"] = heads[0]["family"]
|
||||||
|
if bodies:
|
||||||
|
book["typography_body"] = bodies[0]["family"]
|
||||||
|
note = vis.get("source") or ""
|
||||||
|
|
||||||
|
# Visual notes: honest provenance
|
||||||
|
book["visual_notes"] = (
|
||||||
|
f"PROCEDENCIA VISUAL: {note}. "
|
||||||
|
"Los hex/fuentes listados salen del HTML/CSS público. "
|
||||||
|
"Sustituir por brand kit oficial (PDF/AI) cuando esté en Drive/Zona de marca. "
|
||||||
|
"NO usar paletas inventadas por IA."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provenance in freeform
|
||||||
|
prev = (book.get("freeform_notes") or "").strip()
|
||||||
|
# remove older provenance blocks to avoid duplication
|
||||||
|
lines = [
|
||||||
|
ln
|
||||||
|
for ln in prev.split("\n")
|
||||||
|
if not ln.startswith("[PROCEDENCIA")
|
||||||
|
and "PROCEDENCIA VISUAL" not in ln
|
||||||
|
]
|
||||||
|
book["freeform_notes"] = (
|
||||||
|
PROVENANCE_HEADER
|
||||||
|
+ (f"Visual: {note}. " if note else "")
|
||||||
|
+ ("\n" + "\n".join(lines) if lines else "")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
# Clear weak invented claims that often aren't on-site
|
||||||
|
# Keep website/handles/products — those were verified in packs
|
||||||
|
# Soften proof_points that cite unverified stats
|
||||||
|
if slug == "la-freseria":
|
||||||
|
# don't keep invented franchise € figures as hard fact without date stamp
|
||||||
|
pps = book.get("proof_points") or []
|
||||||
|
book["proof_points"] = [
|
||||||
|
p
|
||||||
|
for p in pps
|
||||||
|
if "65.000" not in p and "EBITDA" not in p
|
||||||
|
] + [
|
||||||
|
"Claim web/franquicia: 'las fresas más famosas del mundo' (lafreseria.es)",
|
||||||
|
"IG oficial @lafreseriaoficial",
|
||||||
|
"LinkedIn company/la-freseria",
|
||||||
|
]
|
||||||
|
|
||||||
|
if slug == "equinsa-parking":
|
||||||
|
# keep qualitative; avoid conflicting installation counts
|
||||||
|
book["proof_points"] = [
|
||||||
|
"Web: especializada en sistemas de gestión de aparcamientos (equinsaparking.com)",
|
||||||
|
"LinkedIn company/equinsa-parking",
|
||||||
|
"Sede comunicada: C. Primavera 16, Torrejón de Ardoz (web)",
|
||||||
|
"Contacto web: sat@equinsa.es / +34 618 18 89 24",
|
||||||
|
]
|
||||||
|
|
||||||
|
patch(f"documents?id=eq.{docs[0]['id']}", {"content": book})
|
||||||
|
n_col = len(book.get("colors") or [])
|
||||||
|
n_font = len(book.get("fonts_kit") or [])
|
||||||
|
print(f"[ok] {slug:22} colors={n_col} fonts={n_font} · {note[:80]}")
|
||||||
|
|
||||||
|
print("DONE — invented kits removed; verified web signals applied")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
135
scripts/rename_brand_slugs.ts
Normal file
135
scripts/rename_brand_slugs.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Rename portfolio brand slugs to canonical codes:
|
||||||
|
* talento-y-deporte → tyd
|
||||||
|
* talento-scholar-program → tsp
|
||||||
|
* de-tu-a-tu-formacion → dtatf
|
||||||
|
* ecp → ecp (unchanged)
|
||||||
|
*
|
||||||
|
* npx tsx scripts/rename_brand_slugs.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
import {
|
||||||
|
BRAND_DISPLAY_NAMES,
|
||||||
|
brandSiblingNote,
|
||||||
|
} from '../src/lib/tenant-brand-aliases';
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RENAMES: { from: string; to: keyof typeof BRAND_DISPLAY_NAMES }[] = [
|
||||||
|
{ from: 'talento-y-deporte', to: 'tyd' },
|
||||||
|
{ from: 'talento-scholar-program', to: 'tsp' },
|
||||||
|
{ from: 'de-tu-a-tu-formacion', to: 'dtatf' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const r = await fetch(`${url}/rest/v1/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...h, ...(init?.headers || {}) },
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
|
||||||
|
return text ? (JSON.parse(text) as T) : ([] as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const tenants = await rest<{ id: string; slug: string; name: string; agency_notes?: string }[]>(
|
||||||
|
'tenants?select=id,slug,name,agency_notes'
|
||||||
|
);
|
||||||
|
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
|
||||||
|
|
||||||
|
for (const { from, to } of RENAMES) {
|
||||||
|
const existingTo = bySlug[to];
|
||||||
|
const fromRow = bySlug[from];
|
||||||
|
if (existingTo && !fromRow) {
|
||||||
|
console.log(`[${to}] already canonical`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fromRow) {
|
||||||
|
console.log(`[skip] ${from} not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingTo && existingTo.id !== fromRow.id) {
|
||||||
|
throw new Error(`Conflict: both ${from} and ${to} exist`);
|
||||||
|
}
|
||||||
|
const sib = brandSiblingNote(to);
|
||||||
|
const notes = [
|
||||||
|
`[${today}] Slug canónico: ${to} · ${BRAND_DISPLAY_NAMES[to]}.`,
|
||||||
|
sib,
|
||||||
|
fromRow.agency_notes || '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, 4000);
|
||||||
|
|
||||||
|
await rest(`tenants?id=eq.${fromRow.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: to,
|
||||||
|
name: BRAND_DISPLAY_NAMES[to],
|
||||||
|
agency_followed: true,
|
||||||
|
agency_notes: notes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`[rename] ${from} → ${to}`);
|
||||||
|
bySlug[to] = { ...fromRow, slug: to, name: BRAND_DISPLAY_NAMES[to] };
|
||||||
|
delete bySlug[from];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ECP display name
|
||||||
|
const ecp = bySlug['ecp'];
|
||||||
|
if (ecp) {
|
||||||
|
await rest(`tenants?id=eq.${ecp.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: BRAND_DISPLAY_NAMES.ecp,
|
||||||
|
agency_followed: true,
|
||||||
|
agency_notes: [
|
||||||
|
`[${today}] Marca canónica ECP (separada de DTATF).`,
|
||||||
|
brandSiblingNote('ecp'),
|
||||||
|
ecp.agency_notes || '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, 4000),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log('[ecp] display name refreshed');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of ['tyd', 'tsp', 'ecp', 'dtatf'] as const) {
|
||||||
|
const t = bySlug[slug] || (await rest<{ id: string; slug: string }[]>(
|
||||||
|
`tenants?slug=eq.${slug}&select=id,slug&limit=1`
|
||||||
|
))[0];
|
||||||
|
if (!t) {
|
||||||
|
console.log(`[missing] ${slug}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
|
||||||
|
const drive = await ensureCreativesDriveFolders(t.id, slug);
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
164
scripts/seed_blue_parrot_pipeline.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Carga las 33 piezas de Blue Parrot (oct 2026 - ene 2027) en creative_pipeline.
|
||||||
|
*
|
||||||
|
* Requiere:
|
||||||
|
* 1. Haber aplicado sql/creative_pipeline.sql en Supabase.
|
||||||
|
* 2. Haber descomprimido Blue-Parrot-creatividades-con-imagen.zip en una carpeta.
|
||||||
|
* 3. .env.local con NEXT_PUBLIC_SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY reales
|
||||||
|
* (los mismos que usa la app en producción — ejecutar esto en el propio VPS,
|
||||||
|
* por ejemplo desde "Open Terminal" en Dokploy, nunca pegando la service
|
||||||
|
* role key en un chat).
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* npx tsx scripts/seed_blue_parrot_pipeline.ts --dir /ruta/a/Blue-Parrot-creatividades-con-imagen \
|
||||||
|
* [--status revision_interna] [--dry-run]
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve, join } from 'path';
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
// Mismo tenant_id que scripts/provision_victor_bps.ts
|
||||||
|
const BLUE_PARROT_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const BUCKET = 'tenant-assets';
|
||||||
|
|
||||||
|
type SeedItem = {
|
||||||
|
id: string;
|
||||||
|
format: 'post' | 'story' | 'reel' | 'carrusel';
|
||||||
|
month: string;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
if (i === -1) return fallback;
|
||||||
|
return process.argv[i + 1];
|
||||||
|
}
|
||||||
|
const DRY_RUN = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local');
|
||||||
|
|
||||||
|
const dir = arg('dir');
|
||||||
|
if (!dir || !existsSync(dir)) {
|
||||||
|
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
|
||||||
|
}
|
||||||
|
const status = arg('status', 'revision_agencia')!;
|
||||||
|
|
||||||
|
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
|
||||||
|
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
||||||
|
|
||||||
|
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||||
|
|
||||||
|
// Asegura el bucket (no falla si ya existe).
|
||||||
|
const { data: buckets } = await admin.storage.listBuckets();
|
||||||
|
if (!buckets?.some((b) => b.id === BUCKET)) {
|
||||||
|
await admin.storage.createBucket(BUCKET, { public: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const it of items) {
|
||||||
|
const isCarrusel = it.format === 'carrusel';
|
||||||
|
const coverLocal = isCarrusel
|
||||||
|
? join(dir, `${it.id}-carrusel`, `${it.id}-1.jpg`)
|
||||||
|
: join(dir, `${it.id}-${it.format}.jpg`);
|
||||||
|
|
||||||
|
if (!existsSync(coverLocal)) {
|
||||||
|
console.error(`[SKIP] ${it.id}: no existe ${coverLocal}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${it.id}] ${it.title}`);
|
||||||
|
if (DRY_RUN) {
|
||||||
|
ok++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) upsert de la fila (sin imágenes todavía) para obtener el id.
|
||||||
|
const { data: row, error: upErr } = await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
tenant_id: BLUE_PARROT_TENANT_ID,
|
||||||
|
external_ref: it.id,
|
||||||
|
title: it.title,
|
||||||
|
format: it.format,
|
||||||
|
month: it.month,
|
||||||
|
status,
|
||||||
|
notes: it.category,
|
||||||
|
},
|
||||||
|
{ onConflict: 'tenant_id,external_ref' }
|
||||||
|
)
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (upErr || !row) {
|
||||||
|
console.error(`[FAIL] ${it.id}: ${upErr?.message}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const itemId = row.id as string;
|
||||||
|
|
||||||
|
// 2) sube portada
|
||||||
|
const coverPath = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
|
||||||
|
const coverBuf = readFileSync(coverLocal);
|
||||||
|
const coverUp = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (coverUp.error) {
|
||||||
|
console.error(`[FAIL cover] ${it.id}: ${coverUp.error.message}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) sube slides (carrusel: 2,3,4,5 → 4 slides interiores + CTA)
|
||||||
|
const slidePaths: string[] = [];
|
||||||
|
if (isCarrusel) {
|
||||||
|
for (let n = 2; n <= 5; n++) {
|
||||||
|
const local = join(dir, `${it.id}-carrusel`, `${it.id}-${n}.jpg`);
|
||||||
|
if (!existsSync(local)) continue;
|
||||||
|
const path = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/slide-${n - 1}.jpg`;
|
||||||
|
const buf = readFileSync(local);
|
||||||
|
const up = await admin.storage.from(BUCKET).upload(path, buf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (up.error) {
|
||||||
|
console.error(`[FAIL slide ${n}] ${it.id}: ${up.error.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
slidePaths.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) cierra la fila con las rutas de storage
|
||||||
|
const { error: patchErr } = await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.update({ cover_path: coverPath, cover_mime: 'image/jpeg', slide_paths: slidePaths })
|
||||||
|
.eq('id', itemId);
|
||||||
|
|
||||||
|
if (patchErr) {
|
||||||
|
console.error(`[FAIL patch] ${it.id}: ${patchErr.message}`);
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nHecho: ${ok} ok, ${failed} con error, de ${items.length} piezas.`);
|
||||||
|
if (failed > 0) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
143
scripts/seed_brandbooks_regen_nov.ts
Normal file
143
scripts/seed_brandbooks_regen_nov.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Seed brandbook for every tenant + regenerate Nov 2026 editorial strategy.
|
||||||
|
* Usage: npx tsx scripts/seed_brandbooks_regen_nov.ts
|
||||||
|
* Optional: MONTH=2026-11 FORCE_BRANDBOOK=1
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import type { RrssMonthPlan } from '../src/components/RrssMonthView';
|
||||||
|
import { generateMonthPreview } from '../src/lib/calendar-generate';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
parseBrandbook,
|
||||||
|
seedBrandbook,
|
||||||
|
} from '../src/lib/brandbook';
|
||||||
|
import { monthLabel } from '../src/lib/calendar-import';
|
||||||
|
|
||||||
|
const ym = process.env.MONTH || '2026-11';
|
||||||
|
const forceBook = process.env.FORCE_BRANDBOOK === '1';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenants = (await (
|
||||||
|
await fetch(
|
||||||
|
`${url}/rest/v1/tenants?select=id,slug,name&order=name.asc`,
|
||||||
|
{ headers: h }
|
||||||
|
)
|
||||||
|
).json()) as { id: string; slug: string; name: string }[];
|
||||||
|
|
||||||
|
console.log(`Tenants: ${tenants.length} · month ${ym} · forceBook=${forceBook}`);
|
||||||
|
|
||||||
|
for (const t of tenants) {
|
||||||
|
// --- brandbook ---
|
||||||
|
const bbRes = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const bbRows = (await bbRes.json()) as { id: string; content: unknown }[];
|
||||||
|
const existing = parseBrandbook(bbRows[0]?.content);
|
||||||
|
let book = existing;
|
||||||
|
if (!existing || forceBook) {
|
||||||
|
book = seedBrandbook(t.slug, t.name);
|
||||||
|
if (bbRows[0]?.id) {
|
||||||
|
await fetch(`${url}/rest/v1/documents?id=eq.${bbRows[0].id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: book,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`[${t.slug}] brandbook updated`);
|
||||||
|
} else {
|
||||||
|
await fetch(`${url}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: t.id,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: book,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(`[${t.slug}] brandbook created`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[${t.slug}] brandbook exists (skip)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- calendars ---
|
||||||
|
const calRes = await fetch(
|
||||||
|
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
|
||||||
|
{ headers: h }
|
||||||
|
);
|
||||||
|
const docs = (await calRes.json()) as { id: string; content: unknown }[];
|
||||||
|
const history: RrssMonthPlan[] = [];
|
||||||
|
let novId: string | null = null;
|
||||||
|
for (const d of docs) {
|
||||||
|
const c =
|
||||||
|
typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
|
||||||
|
const plan = c as RrssMonthPlan;
|
||||||
|
if (plan?.month === ym) novId = d.id;
|
||||||
|
else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateMonthPreview({
|
||||||
|
ym,
|
||||||
|
slug: t.slug,
|
||||||
|
label: t.name,
|
||||||
|
history,
|
||||||
|
brandbook: book,
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = `RRSS ${monthLabel(ym)}`;
|
||||||
|
if (novId) {
|
||||||
|
const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: result.plan,
|
||||||
|
title,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[${t.slug}] ${ym} patched ${patch.status} · ${result.plan.pieces.length} pcs · ${result.source}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const ins = await fetch(`${url}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...h, Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: t.id,
|
||||||
|
type: 'calendar',
|
||||||
|
title,
|
||||||
|
status: 'draft',
|
||||||
|
content: result.plan,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[${t.slug}] ${ym} created ${ins.status} · ${result.plan.pieces.length} pcs · ${result.source}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
272
scripts/split_tenants_and_fix.ts
Normal file
272
scripts/split_tenants_and_fix.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio fix:
|
||||||
|
* - Menttum: confirm archived (no longer a client)
|
||||||
|
* - De Tú a Tú Formación ↔ ECP: separate as two active clients (move ECP social/ads)
|
||||||
|
* - Create Talento Scholar Program as sibling of Talento y Deporte
|
||||||
|
* - Sync tenants.drive_folder_id from google_drive integrations
|
||||||
|
* - Seed DNA + enrich brandbooks for affected + active portfolio
|
||||||
|
*
|
||||||
|
* npx tsx scripts/split_tenants_and_fix.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||||
|
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const h = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rest<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const r = await fetch(`${url}/rest/v1/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...h, ...(init?.headers || {}) },
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 400)}`);
|
||||||
|
if (!text) return [] as T;
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchTenant(
|
||||||
|
id: string,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await rest(`tenants?id=eq.${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchIntegration(
|
||||||
|
tenantId: string,
|
||||||
|
provider: string,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
await rest(
|
||||||
|
`integrations?tenant_id=eq.${tenantId}&provider=eq.${provider}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Prefer: 'return=minimal' },
|
||||||
|
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const tenants = await rest<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
agency_followed?: boolean;
|
||||||
|
agency_notes?: string | null;
|
||||||
|
drive_folder_id?: string | null;
|
||||||
|
}[]
|
||||||
|
>('tenants?select=id,slug,name,agency_followed,agency_notes,drive_folder_id&order=name.asc');
|
||||||
|
|
||||||
|
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
|
||||||
|
|
||||||
|
// ——— 1. Menttum: archived, not a client ———
|
||||||
|
const menttum = bySlug['menttum'];
|
||||||
|
if (menttum) {
|
||||||
|
await patchTenant(menttum.id, {
|
||||||
|
agency_followed: false,
|
||||||
|
agency_notes: `[archivado ${today}] Ya no es cliente 4Ventures. No enriquecer / no sincronizar creatividades.\n${menttum.agency_notes || ''}`.slice(
|
||||||
|
0,
|
||||||
|
4000
|
||||||
|
),
|
||||||
|
creatives_enabled: false,
|
||||||
|
});
|
||||||
|
for (const p of [
|
||||||
|
'meta_ads',
|
||||||
|
'facebook',
|
||||||
|
'instagram',
|
||||||
|
'google_ads',
|
||||||
|
'google_drive',
|
||||||
|
'ga4',
|
||||||
|
'search_console',
|
||||||
|
]) {
|
||||||
|
await patchIntegration(menttum.id, p, {
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: false,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
console.log('[menttum] archived + integrations disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— 2. Separate De Tú a Tú vs ECP ———
|
||||||
|
const detu = bySlug['de-tu-a-tu-formacion'];
|
||||||
|
const ecp = bySlug['ecp'];
|
||||||
|
if (!detu || !ecp) throw new Error('missing de-tu-a-tu-formacion or ecp');
|
||||||
|
|
||||||
|
// Activate ECP as its own client
|
||||||
|
await patchTenant(ecp.id, {
|
||||||
|
agency_followed: true,
|
||||||
|
name: 'ECP · Escuela de Cuidadores',
|
||||||
|
creatives_enabled: false,
|
||||||
|
default_landing_url: 'https://escueladecuidadoresprofesionales.com',
|
||||||
|
meta_ad_account_id: 'act_1320801442642004',
|
||||||
|
meta_page_id: '111175967913754',
|
||||||
|
meta_ig_user_id: '17841453624785447',
|
||||||
|
agency_notes: `[${today}] Cliente separado de De Tú a Tú Formación. Marca: Escuela de Cuidadores Profesionales (ECP). Meta act_1320801442642004 · IG @escuelaecp · web escueladecuidadoresprofesionales.com. Drive: buscar carpeta cliente ECP/Cuidadores (evitar «Navidad ecp» si no es raíz).`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move ECP social/ads ownership onto ECP tenant (connect ECP rows)
|
||||||
|
await patchIntegration(ecp.id, 'meta_ads', {
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: 'act_1320801442642004',
|
||||||
|
label: 'ECP',
|
||||||
|
});
|
||||||
|
await patchIntegration(ecp.id, 'facebook', {
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: '111175967913754',
|
||||||
|
label: 'Escuela de cuidadores profesionales',
|
||||||
|
});
|
||||||
|
await patchIntegration(ecp.id, 'instagram', {
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: '17841453624785447',
|
||||||
|
label: '@escuelaecp',
|
||||||
|
});
|
||||||
|
await patchIntegration(ecp.id, 'ga4', {
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
external_account_id: 'G-82HW868X9L',
|
||||||
|
label: 'Web escueladecuidadoresprofesionales.com - GA4',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detach ECP assets from De Tú a Tú
|
||||||
|
await patchIntegration(detu.id, 'meta_ads', {
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: false,
|
||||||
|
external_account_id: 'act_413393941247349',
|
||||||
|
label: 'Detuatuformacion (pendiente permiso API system user)',
|
||||||
|
});
|
||||||
|
await patchIntegration(detu.id, 'facebook', {
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: false,
|
||||||
|
external_account_id: null,
|
||||||
|
label: 'Facebook Page (pendiente página propia De Tú a Tú)',
|
||||||
|
});
|
||||||
|
await patchIntegration(detu.id, 'instagram', {
|
||||||
|
status: 'disconnected',
|
||||||
|
enabled: false,
|
||||||
|
external_account_id: null,
|
||||||
|
label: 'Instagram (pendiente cuenta propia De Tú a Tú)',
|
||||||
|
});
|
||||||
|
|
||||||
|
await patchTenant(detu.id, {
|
||||||
|
agency_followed: true,
|
||||||
|
name: 'De Tú a Tú Formación',
|
||||||
|
creatives_enabled: false,
|
||||||
|
meta_ad_account_id: 'act_413393941247349',
|
||||||
|
meta_page_id: null,
|
||||||
|
meta_ig_user_id: null,
|
||||||
|
default_landing_url: null,
|
||||||
|
drive_folder_id: '1PWUL2sn18n9bIURwfuc8YIKfhbl1BQfg',
|
||||||
|
agency_notes: `[${today}] Separado de ECP. Marca De Tú a Tú Formación. Google Ads 3183140753 · Drive 08-Detuatu · Meta act_413393941247349 (otorgar ads_read al system user API 4V2030). RRSS/Meta page propias pendientes; ECP ya no se gestiona aquí.`,
|
||||||
|
});
|
||||||
|
console.log('[detuatu / ecp] separated');
|
||||||
|
|
||||||
|
// ——— 3. Create Talento Scholar Program ———
|
||||||
|
let scholar = bySlug['talento-scholar-program'];
|
||||||
|
if (!scholar) {
|
||||||
|
const created = await rest<
|
||||||
|
{ id: string; slug: string; name: string }[]
|
||||||
|
>('tenants', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: 'talento-scholar-program',
|
||||||
|
name: 'Talento Scholar Program',
|
||||||
|
agency_followed: true,
|
||||||
|
creatives_enabled: false,
|
||||||
|
agency_notes: `[${today}] Separado de Talento y Deporte. Línea académica / Scholar (becas de estudio USA). Emparejar Meta Ads, IG y Drive propios cuando existan.`,
|
||||||
|
default_landing_url: 'https://talentoydeporte.com',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
scholar = created[0];
|
||||||
|
console.log('[talento-scholar-program] created', scholar?.id);
|
||||||
|
} else {
|
||||||
|
await patchTenant(scholar.id, {
|
||||||
|
agency_followed: true,
|
||||||
|
name: 'Talento Scholar Program',
|
||||||
|
agency_notes: `[${today}] Cliente separado de Talento y Deporte · línea Scholar / becas académicas.\n${scholar.agency_notes || ''}`.slice(
|
||||||
|
0,
|
||||||
|
4000
|
||||||
|
),
|
||||||
|
});
|
||||||
|
console.log('[talento-scholar-program] already existed — activated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed empty integrations row set is usually done by app on first open;
|
||||||
|
// ensure notes on Talento y Deporte clarify split
|
||||||
|
const tyd = bySlug['talento-y-deporte'];
|
||||||
|
if (tyd) {
|
||||||
|
await patchTenant(tyd.id, {
|
||||||
|
agency_followed: true,
|
||||||
|
name: 'Talento y Deporte',
|
||||||
|
drive_folder_id: '1IwXdvcVFKpHta4jf3nxT7k7qltzSufuL',
|
||||||
|
default_landing_url: 'https://talentoydeporte.com',
|
||||||
|
meta_ad_account_id: 'act_1188310622211934',
|
||||||
|
meta_page_id: '189855164776887',
|
||||||
|
meta_ig_user_id: '17841405373441110',
|
||||||
|
agency_notes: `[${today}] Línea deportiva / becas deportivas USA. Sibling: Talento Scholar Program (académico). Drive 06-Talento y Deporte · IG @talentoydeporte.`,
|
||||||
|
});
|
||||||
|
console.log('[talento-y-deporte] notes + ids synced');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— 4. Sync drive_folder_id from google_drive for all followed tenants ———
|
||||||
|
const driveRows = await rest<
|
||||||
|
{ tenant_id: string; external_account_id: string; label?: string }[]
|
||||||
|
>(
|
||||||
|
'integrations?provider=eq.google_drive&status=eq.connected&select=tenant_id,external_account_id,label'
|
||||||
|
);
|
||||||
|
for (const row of driveRows) {
|
||||||
|
// Skip bad ECP christmas folder as root — keep label but don't force as client root if name is Navidad
|
||||||
|
if (/navidad/i.test(row.label || '')) {
|
||||||
|
console.log(
|
||||||
|
`[drive] skip suspicious folder for tenant ${row.tenant_id}: ${row.label}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await patchTenant(row.tenant_id, {
|
||||||
|
drive_folder_id: row.external_account_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[drive] synced folder ids for ${driveRows.length} tenants`);
|
||||||
|
|
||||||
|
// ——— 5. Enrich brandbooks for active portfolio (skip archived) ———
|
||||||
|
const active = await rest<{ id: string; slug: string; name: string }[]>(
|
||||||
|
'tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc'
|
||||||
|
);
|
||||||
|
for (const t of active) {
|
||||||
|
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
|
||||||
|
const drive = await ensureCreativesDriveFolders(t.id, t.slug);
|
||||||
|
console.log(
|
||||||
|
`[enrich] ${t.slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done · active tenants', active.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
145
scripts/sync_bps_gdoc_days.ts
Normal file
145
scripts/sync_bps_gdoc_days.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import {
|
||||||
|
exportGoogleDocPlain,
|
||||||
|
exportGoogleDocHtml,
|
||||||
|
upsertSeguimientoRows,
|
||||||
|
patchSuperhumanIntegration,
|
||||||
|
} from '../src/lib/superhuman-docs';
|
||||||
|
import { getValidAccessToken } from '../src/lib/google-drive';
|
||||||
|
|
||||||
|
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
|
||||||
|
const DOC = '7TGywaNiUY';
|
||||||
|
|
||||||
|
function parseDayLabel(raw: string): { dateIso: string; label: string } | null {
|
||||||
|
const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
let y = Number(m[3]);
|
||||||
|
const mo = Number(m[2]);
|
||||||
|
const d = Number(m[1]);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||||
|
return {
|
||||||
|
dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
|
||||||
|
label: `${d}/${mo}/${String(y).slice(2)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitNotesByDay(text: string) {
|
||||||
|
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||||
|
const dayRe =
|
||||||
|
/^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/;
|
||||||
|
const lines = clean.split('\n');
|
||||||
|
const hits: { idx: number; label: string; dateIso: string }[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const m = lines[i].trim().match(dayRe);
|
||||||
|
if (!m) continue;
|
||||||
|
const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`);
|
||||||
|
if (!parsed) continue;
|
||||||
|
hits.push({ idx: i, ...parsed });
|
||||||
|
}
|
||||||
|
if (!hits.length) return [] as { dateIso: string; label: string; body: string }[];
|
||||||
|
const sections: { dateIso: string; label: string; body: string }[] = [];
|
||||||
|
for (let h = 0; h < hits.length; h++) {
|
||||||
|
const start = hits[h].idx + 1;
|
||||||
|
const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length;
|
||||||
|
const body = lines
|
||||||
|
.slice(start, end)
|
||||||
|
.join('\n')
|
||||||
|
.replace(/^\n+/, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
if (!body) continue;
|
||||||
|
sections.push({ dateIso: hits[h].dateIso, label: hits[h].label, body });
|
||||||
|
}
|
||||||
|
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportViaDownload(fileId: string, mime: string) {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(mime)}`;
|
||||||
|
const r = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${mime} ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
||||||
|
return await r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let plain = '';
|
||||||
|
try {
|
||||||
|
plain = await exportGoogleDocPlain(FILE);
|
||||||
|
console.log('plain via helper', plain.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('plain helper fail', e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
if (!plain) {
|
||||||
|
try {
|
||||||
|
plain = await exportViaDownload(FILE, 'text/plain');
|
||||||
|
console.log('plain via download', plain.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('plain download fail', e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const html = await exportGoogleDocHtml(FILE);
|
||||||
|
console.log('html ok', html.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('html fail', e instanceof Error ? e.message : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plain) throw new Error('no plain export');
|
||||||
|
console.log('--- sample ---\n', plain.slice(0, 2000));
|
||||||
|
const secs = splitNotesByDay(plain);
|
||||||
|
console.log('sections', secs.length, secs.slice(0, 8).map((s) => s.label));
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const rows = secs.slice(0, 120).map((sec) => {
|
||||||
|
const firstLine =
|
||||||
|
sec.body
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||||
|
.find((l) => l.length > 8) || '';
|
||||||
|
return {
|
||||||
|
tenant_id: BPS,
|
||||||
|
source: 'superhuman' as const,
|
||||||
|
entry_type: 'meeting' as const,
|
||||||
|
external_id: `gdoc:${FILE}:${sec.dateIso}`,
|
||||||
|
title: `Seguimiento · ${sec.label}`,
|
||||||
|
summary: firstLine.slice(0, 220) || null,
|
||||||
|
body_md: `<pre>${sec.body
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')}</pre>`,
|
||||||
|
browser_link: `https://docs.google.com/document/d/${FILE}/edit`,
|
||||||
|
metrics: {},
|
||||||
|
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||||
|
synced_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const n = await upsertSeguimientoRows(rows);
|
||||||
|
await patchSuperhumanIntegration(BPS, {
|
||||||
|
status: 'connected',
|
||||||
|
external_account_id: DOC,
|
||||||
|
label: '01.BLUE PARROT SCHOOL',
|
||||||
|
last_sync_at: now,
|
||||||
|
error_message: null,
|
||||||
|
meta: {
|
||||||
|
doc_name: '01.BLUE PARROT SCHOOL',
|
||||||
|
gdoc_id: FILE,
|
||||||
|
gdoc_days: n,
|
||||||
|
synced_at: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('upserted day notes', n);
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
292
scripts/sync_bps_instagram_history.ts
Normal file
292
scripts/sync_bps_instagram_history.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Extrae todas las publicaciones de Instagram de Blue Parrot School
|
||||||
|
* (Enero 2026 a Septiembre 2026) vía Meta Graph API:
|
||||||
|
* 1. Descarga portadas y slides de carruseles.
|
||||||
|
* 2. Sube los archivos a Supabase Storage (tenant-assets).
|
||||||
|
* 3. Inserta/actualiza las piezas en public.creative_pipeline (status: 'publicado').
|
||||||
|
* 4. Actualiza/sincroniza en public.documents (type: 'calendar') para reflejarlas en el Calendario.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const BPS_IG_ID = '17841431459801731';
|
||||||
|
const BUCKET = 'tenant-assets';
|
||||||
|
|
||||||
|
const META_TOKEN = process.env.META_ACCESS_TOKEN || '';
|
||||||
|
|
||||||
|
type IgMedia = {
|
||||||
|
id: string;
|
||||||
|
caption?: string;
|
||||||
|
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
|
||||||
|
media_url?: string;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
permalink?: string;
|
||||||
|
timestamp: string;
|
||||||
|
children?: {
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
media_type: string;
|
||||||
|
media_url: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanTitle(caption?: string, date?: string): string {
|
||||||
|
if (!caption) return `Publicación Instagram ${date || ''}`.trim();
|
||||||
|
const firstLine = caption.split('\n')[0].trim();
|
||||||
|
// Quitar hashtags al inicio si los hay
|
||||||
|
const clean = firstLine.replace(/#\S+/g, '').trim();
|
||||||
|
if (clean.length > 5) {
|
||||||
|
return clean.slice(0, 100);
|
||||||
|
}
|
||||||
|
return caption.slice(0, 100).trim() || `Publicación ${date || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBuffer(url: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error descargando ${url}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||||
|
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
|
||||||
|
|
||||||
|
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
|
||||||
|
|
||||||
|
console.log('1. Consultando Meta Graph API para Blue Parrot School (@blueparrotschool)...');
|
||||||
|
const allMedia: IgMedia[] = [];
|
||||||
|
let nextUrl: string | null =
|
||||||
|
`https://graph.facebook.com/v21.0/${BPS_IG_ID}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{id,media_type,media_url}&limit=50&access_token=${META_TOKEN}`;
|
||||||
|
|
||||||
|
while (nextUrl) {
|
||||||
|
const res = await fetch(nextUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Graph API error: ${err}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const items: IgMedia[] = data.data || [];
|
||||||
|
if (!items.length) break;
|
||||||
|
|
||||||
|
let reachedPast = false;
|
||||||
|
for (const it of items) {
|
||||||
|
const date = it.timestamp.slice(0, 10);
|
||||||
|
if (date >= '2026-01-01' && date <= '2026-09-30') {
|
||||||
|
allMedia.push(it);
|
||||||
|
} else if (date < '2026-01-01') {
|
||||||
|
reachedPast = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reachedPast) break;
|
||||||
|
nextUrl = data.paging?.next || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Encontradas ${allMedia.length} publicaciones en Instagram entre 2026-01 y 2026-09.`);
|
||||||
|
|
||||||
|
// Ordenar cronológicamente (antiguo a nuevo)
|
||||||
|
allMedia.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
|
||||||
|
// 2. Subir a Supabase Storage y a public.creative_pipeline
|
||||||
|
console.log('2. Subiendo imágenes a Storage y registrando en public.creative_pipeline...');
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < allMedia.length; i++) {
|
||||||
|
const it = allMedia[i];
|
||||||
|
const date = it.timestamp.slice(0, 10);
|
||||||
|
const month = date.slice(0, 7);
|
||||||
|
const isCarousel = it.media_type === 'CAROUSEL_ALBUM';
|
||||||
|
const isReel = it.media_type === 'VIDEO';
|
||||||
|
const format = isCarousel ? 'carrusel' : isReel ? 'reel' : 'post';
|
||||||
|
const title = cleanTitle(it.caption, date);
|
||||||
|
|
||||||
|
// Obtener URL de portada
|
||||||
|
const coverUrl = isReel
|
||||||
|
? (it.thumbnail_url || it.media_url)
|
||||||
|
: isCarousel
|
||||||
|
? (it.children?.data?.[0]?.media_url || it.media_url)
|
||||||
|
: it.media_url;
|
||||||
|
|
||||||
|
const extRef = `ig-${it.id}`;
|
||||||
|
|
||||||
|
// 1) Upsert fila base en creative_pipeline
|
||||||
|
const { data: row, error: upErr } = await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
tenant_id: BPS_TENANT_ID,
|
||||||
|
external_ref: extRef,
|
||||||
|
title,
|
||||||
|
format,
|
||||||
|
month,
|
||||||
|
status: 'publicado',
|
||||||
|
caption: it.caption || null,
|
||||||
|
notes: it.permalink ? `Instagram: ${it.permalink}` : null,
|
||||||
|
created_at: it.timestamp,
|
||||||
|
},
|
||||||
|
{ onConflict: 'tenant_id,external_ref' }
|
||||||
|
)
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (upErr || !row) {
|
||||||
|
console.error(`[ERROR upsert] ${extRef}: ${upErr?.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const itemId = row.id as string;
|
||||||
|
|
||||||
|
// 2) Descargar y subir portada
|
||||||
|
let coverPath: string | null = null;
|
||||||
|
if (coverUrl) {
|
||||||
|
const coverBuf = await fetchBuffer(coverUrl);
|
||||||
|
if (coverBuf) {
|
||||||
|
coverPath = `${BPS_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
|
||||||
|
const upRes = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (upRes.error) {
|
||||||
|
console.error(`[WARN cover upload] ${extRef}: ${upRes.error.message}`);
|
||||||
|
coverPath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Si es carrusel, descargar y subir slides
|
||||||
|
const slidePaths: string[] = [];
|
||||||
|
if (isCarousel && it.children?.data) {
|
||||||
|
const slides = it.children.data.slice(1); // slides posteriores a la portada
|
||||||
|
for (let sIdx = 0; sIdx < slides.length; sIdx++) {
|
||||||
|
const slide = slides[sIdx];
|
||||||
|
if (!slide.media_url) continue;
|
||||||
|
const slideBuf = await fetchBuffer(slide.media_url);
|
||||||
|
if (slideBuf) {
|
||||||
|
const sPath = `${BPS_TENANT_ID}/pipeline/${itemId}/slide-${sIdx + 1}.jpg`;
|
||||||
|
const sUp = await admin.storage.from(BUCKET).upload(sPath, slideBuf, {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (!sUp.error) {
|
||||||
|
slidePaths.push(sPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Actualizar registro con las rutas de storage
|
||||||
|
await admin
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.update({
|
||||||
|
cover_path: coverPath,
|
||||||
|
cover_mime: coverPath ? 'image/jpeg' : null,
|
||||||
|
slide_paths: slidePaths,
|
||||||
|
})
|
||||||
|
.eq('id', itemId);
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
console.log(`[${i + 1}/${allMedia.length}] ${month} [${format}] ${title} (${slidePaths.length} slides)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nCreatividades cargadas en creative_pipeline: ${successCount} de ${allMedia.length}.`);
|
||||||
|
|
||||||
|
// 3. Sincronizar en public.documents (type = 'calendar')
|
||||||
|
console.log('\n3. Sincronizando con public.documents para el Calendario...');
|
||||||
|
// Agrupar las publicaciones de Instagram por mes
|
||||||
|
const igByMonth = new Map<string, IgMedia[]>();
|
||||||
|
for (const m of allMedia) {
|
||||||
|
const ym = m.timestamp.slice(0, 7);
|
||||||
|
const list = igByMonth.get(ym) || [];
|
||||||
|
list.push(m);
|
||||||
|
igByMonth.set(ym, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: allDocs } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, title, content')
|
||||||
|
.eq('tenant_id', BPS_TENANT_ID)
|
||||||
|
.eq('type', 'calendar');
|
||||||
|
|
||||||
|
const docsList = allDocs || [];
|
||||||
|
|
||||||
|
for (const [ym, igList] of igByMonth.entries()) {
|
||||||
|
// Buscar documento del mes
|
||||||
|
const doc = docsList.find((d) => {
|
||||||
|
try {
|
||||||
|
const c = typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
|
||||||
|
return c?.month === ym;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (doc) {
|
||||||
|
let content = typeof doc.content === 'string' ? JSON.parse(doc.content) : doc.content;
|
||||||
|
const pieces: any[] = content?.pieces || [];
|
||||||
|
|
||||||
|
// Mapear posts de IG en piezas de calendario si no están
|
||||||
|
for (const ig of igList) {
|
||||||
|
const date = ig.timestamp.slice(0, 10);
|
||||||
|
const title = cleanTitle(ig.caption, date);
|
||||||
|
const isReel = ig.media_type === 'VIDEO';
|
||||||
|
const isCarousel = ig.media_type === 'CAROUSEL_ALBUM';
|
||||||
|
const format = isCarousel ? 'Carrusel' : isReel ? 'Reel' : 'Post';
|
||||||
|
|
||||||
|
// Buscar si ya existe una pieza en esa fecha
|
||||||
|
const match = pieces.find(
|
||||||
|
(p) => p.date === date && (p.title?.toLowerCase().includes(title.slice(0, 15).toLowerCase()) || p.platform === 'Instagram')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
match.status = 'published';
|
||||||
|
match.meta_line = `Instagram: ${ig.permalink || ''}`;
|
||||||
|
if (!match.body && ig.caption) match.body = ig.caption;
|
||||||
|
} else {
|
||||||
|
// Agregar nueva pieza publicada
|
||||||
|
pieces.push({
|
||||||
|
id: `bps-${date}-ig-${ig.id}`,
|
||||||
|
date,
|
||||||
|
platform: 'Instagram',
|
||||||
|
format,
|
||||||
|
pillar: isReel ? 'reels' : isCarousel ? 'carrusel' : 'feed',
|
||||||
|
title,
|
||||||
|
body: ig.caption || undefined,
|
||||||
|
status: 'published',
|
||||||
|
kind: isReel ? 'reel' : 'post',
|
||||||
|
color: isReel ? '#7C66B8' : isCarousel ? '#2071cf' : '#0F9D8F',
|
||||||
|
meta_line: `Instagram: ${ig.permalink || ''}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar piezas por fecha
|
||||||
|
pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
content.pieces = pieces;
|
||||||
|
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({ content })
|
||||||
|
.eq('id', doc.id);
|
||||||
|
|
||||||
|
console.log(`Mes ${ym} actualizado en Calendario: ${pieces.length} piezas.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSincronización completada con éxito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
59
scripts/sync_bps_seguimiento.ts
Normal file
59
scripts/sync_bps_seguimiento.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSeguimientoRowsForDoc,
|
||||||
|
upsertSeguimientoRows,
|
||||||
|
patchSuperhumanIntegration,
|
||||||
|
findSeguimientoGoogleDoc,
|
||||||
|
} from '../src/lib/superhuman-docs';
|
||||||
|
|
||||||
|
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
const DOC = '7TGywaNiUY';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hit = await findSeguimientoGoogleDoc([
|
||||||
|
'Seguimiento BPS',
|
||||||
|
'Seguimiento Semanal BPS',
|
||||||
|
'Seguimiento Blue Parrot School',
|
||||||
|
'Seguimiento Semanal Blue Parrot School',
|
||||||
|
]);
|
||||||
|
console.log('drive hit', hit);
|
||||||
|
|
||||||
|
const rows = await buildSeguimientoRowsForDoc({
|
||||||
|
tenantId: BPS,
|
||||||
|
tenantName: 'BPS',
|
||||||
|
docId: DOC,
|
||||||
|
maxEntries: 120,
|
||||||
|
exportBodies: true,
|
||||||
|
});
|
||||||
|
const gdoc = rows.filter((r) => r.external_id.startsWith('gdoc:'));
|
||||||
|
console.log('total', rows.length, 'gdoc days', gdoc.length);
|
||||||
|
console.log(
|
||||||
|
gdoc.slice(0, 5).map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
day: r.occurred_at,
|
||||||
|
len: (r.body_md || '').length,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const n = await upsertSeguimientoRows(rows);
|
||||||
|
await patchSuperhumanIntegration(BPS, {
|
||||||
|
status: 'connected',
|
||||||
|
external_account_id: DOC,
|
||||||
|
label: '01.BLUE PARROT SCHOOL',
|
||||||
|
last_sync_at: new Date().toISOString(),
|
||||||
|
error_message: null,
|
||||||
|
meta: {
|
||||||
|
doc_name: '01.BLUE PARROT SCHOOL',
|
||||||
|
entries: n,
|
||||||
|
gdoc_days: gdoc.length,
|
||||||
|
synced_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('upserted', n);
|
||||||
|
}
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
37
scripts/sync_ghl.ts
Normal file
37
scripts/sync_ghl.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Sync GoHighLevel contacts/opportunities → warehouse.
|
||||||
|
* npx tsx scripts/sync_ghl.ts
|
||||||
|
* TENANT_SLUG=delgado-abogados npx tsx scripts/sync_ghl.ts
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
import { syncAllGhlTenants } from '../src/lib/gohighlevel-sync';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const slug = process.env.TENANT_SLUG?.trim();
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
if (slug) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) throw new Error('no service role');
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,slug`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const rows = (await r.json()) as { id: string; slug: string }[];
|
||||||
|
if (!rows[0]) throw new Error(`tenant ${slug} not found`);
|
||||||
|
tenantId = rows[0].id;
|
||||||
|
}
|
||||||
|
const out = await syncAllGhlTenants({ tenantId });
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
if (out.results.some((x) => x.error)) process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
75
skills/clients/blue-parrot-school.md
Normal file
75
skills/clients/blue-parrot-school.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Skill de cliente · Blue Parrot School (slug: bps)
|
||||||
|
|
||||||
|
> Esta skill define cómo habla y cómo se ve Blue Parrot School en cualquier creatividad. Se inyecta como contexto fijo a Claude en cada petición de relevo. Si algo aquí contradice un brief puntual, manda esta skill.
|
||||||
|
|
||||||
|
## Quién es
|
||||||
|
|
||||||
|
Academia de idiomas con sede en Madrid y Málaga, clases presenciales, online e in-company. Dos líneas:
|
||||||
|
|
||||||
|
- **B2B (empresas):** programas de inglés profesional para equipos. Diferencia frente a apps conversacionales tipo Twenix: profesor fijo, sesiones de 60 minutos, programas por sector (Inglés Legal, IT, Financiero, Comercial Internacional), modalidad híbrida. Es **Entidad Organizadora de FUNDAE** (gestiona la bonificación directamente, no a través de un partner).
|
||||||
|
- **B2C (particulares):** cursos de inglés para adultos, preparación de exámenes oficiales, intensivos.
|
||||||
|
|
||||||
|
Web: blueparrotschool.com
|
||||||
|
|
||||||
|
## A quién le hablamos
|
||||||
|
|
||||||
|
**B2B**
|
||||||
|
- *Marta* — RRHH / Formación. Quiere un proveedor que no le dé trabajo: gestión FUNDAE resuelta, informes de asistencia, un interlocutor.
|
||||||
|
- *Carlos* — director o socio de despacho / consultora. Le importa que su equipo hable con clientes internacionales sin hacer el ridículo. Compra rigor, no "clases".
|
||||||
|
- *Lucía* — directora comercial / internacional. Necesita resultados medibles en poco tiempo: presentaciones, negociación, llamadas.
|
||||||
|
|
||||||
|
**B2C**
|
||||||
|
- Adultos 25-45 que necesitan el inglés para el trabajo o para un examen (Cambridge, Aptis, IELTS) y han fracasado con apps.
|
||||||
|
|
||||||
|
## Cómo habla
|
||||||
|
|
||||||
|
- **Tono:** profesional, seguro, directo. Cercano sin ser coloquial. Nunca infantil, nunca grito de descuento.
|
||||||
|
- **Postura:** "formación profesional estructurada", no "aprende inglés fácil". Vendemos **programas**, no clases sueltas.
|
||||||
|
- **Español de España**, tú o usted según el público: *tú* en B2C, *tú* en B2B cuando hablamos a la persona, *usted* nunca.
|
||||||
|
- **Frases cortas.** Un dato concreto vale más que un adjetivo.
|
||||||
|
- **Inglés en el copy:** solo nombres propios de exámenes o términos sin traducción natural. Nada de "boost", "unlock", "level up".
|
||||||
|
|
||||||
|
### Argumentos que funcionan
|
||||||
|
1. Profesor fijo + 60 minutos: progreso real, no mantenimiento.
|
||||||
|
2. FUNDAE gestionado por nosotros: coste cero o casi cero para la empresa.
|
||||||
|
3. Programas por sector: tu equipo aprende el inglés de su trabajo.
|
||||||
|
4. Presencial en Madrid y Málaga + online: híbrido de verdad.
|
||||||
|
5. Informes de asistencia y progreso para RRHH.
|
||||||
|
|
||||||
|
### Objeciones a desactivar
|
||||||
|
- "Ya usamos una app" → la app mantiene, no avanza.
|
||||||
|
- "Es caro" → FUNDAE; coste por empleado y mes.
|
||||||
|
- "No tenemos tiempo" → sesiones in-company en horario laboral.
|
||||||
|
|
||||||
|
## Prohibido
|
||||||
|
- Prometer niveles o plazos ("B2 en 3 meses").
|
||||||
|
- Comparar nombrando a la competencia (ni Twenix ni ninguna).
|
||||||
|
- Descuentos agresivos, cuentas atrás, "última oportunidad".
|
||||||
|
- Emojis en creatividades de imagen. En copy, como mucho uno al inicio y solo en B2C.
|
||||||
|
- Fotografía de stock genérica de "gente feliz con portátil" con aspecto de banco de imágenes.
|
||||||
|
- Testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.
|
||||||
|
- Deformar el logo, recolorearlo o recrearlo. Se coloca el archivo oficial.
|
||||||
|
|
||||||
|
## Identidad visual
|
||||||
|
|
||||||
|
- **Paleta exacta y única:** Azul principal `#2A4D9C` · Amarillo `#FBC02D` · Negro `#000000` · Blanco `#FFFFFF`. Ningún otro color.
|
||||||
|
- **Uso de superficies:** azul y blanco como base. Amarillo solo para resaltar una palabra, un dato o el CTA. Negro reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto).
|
||||||
|
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente.
|
||||||
|
- **Logo:** lockup oficial *Blue Parrot* + *SCHOOL* en amarillo; versión con filete blanco sobre fondos azul o negro. Área de protección respetada, esquina inferior o superior, nunca centrado ni dominando.
|
||||||
|
- **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea.
|
||||||
|
- **La imagen tiene que leerse como clase de idioma en 1,5 s.** Siempre un elemento que lo diga sin texto: pizarra con esquemas, libro o cuaderno de ejercicios abierto, auriculares en una sesión online, aula con sillas. Y los roles claros: el profesor de pie, señalando, o con la pizarra detrás; el alumno sentado, escuchando o escribiendo. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta.
|
||||||
|
- **Una persona basta** si el mensaje es individual (adulto en sesión online con auriculares y portátil, mirando a cámara o a la pantalla). Dos personas solo si el mensaje es la relación profesor-alumno. Nunca grupos genéricos.
|
||||||
|
|
||||||
|
## Audiencia por campaña
|
||||||
|
|
||||||
|
El brief trae `audience`. Si no viene, se deduce del nombre de campaña:
|
||||||
|
- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés".
|
||||||
|
- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo".
|
||||||
|
Si hay duda, se pregunta en `notes` y se genera en B2C.
|
||||||
|
- **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto.
|
||||||
|
|
||||||
|
## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)
|
||||||
|
- "Tu equipo no necesita más inglés. Necesita el inglés de su trabajo."
|
||||||
|
- "FUNDAE lo paga. Nosotros lo gestionamos."
|
||||||
|
- "Un profesor. Sesenta minutos. Cada semana. Así se avanza."
|
||||||
|
- "El inglés legal no se aprende en una app."
|
||||||
75
skills/clients/bps.md
Normal file
75
skills/clients/bps.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Skill de cliente · Blue Parrot School (slug: bps)
|
||||||
|
|
||||||
|
> Esta skill define cómo habla y cómo se ve Blue Parrot School en cualquier creatividad. Se inyecta como contexto fijo a Claude en cada petición de relevo. Si algo aquí contradice un brief puntual, manda esta skill.
|
||||||
|
|
||||||
|
## Quién es
|
||||||
|
|
||||||
|
Academia de idiomas con sede en Madrid y Málaga, clases presenciales, online e in-company. Dos líneas:
|
||||||
|
|
||||||
|
- **B2B (empresas):** programas de inglés profesional para equipos. Diferencia frente a apps conversacionales tipo Twenix: profesor fijo, sesiones de 60 minutos, programas por sector (Inglés Legal, IT, Financiero, Comercial Internacional), modalidad híbrida. Es **Entidad Organizadora de FUNDAE** (gestiona la bonificación directamente, no a través de un partner).
|
||||||
|
- **B2C (particulares):** cursos de inglés para adultos, preparación de exámenes oficiales, intensivos.
|
||||||
|
|
||||||
|
Web: blueparrotschool.com
|
||||||
|
|
||||||
|
## A quién le hablamos
|
||||||
|
|
||||||
|
**B2B**
|
||||||
|
- *Marta* — RRHH / Formación. Quiere un proveedor que no le dé trabajo: gestión FUNDAE resuelta, informes de asistencia, un interlocutor.
|
||||||
|
- *Carlos* — director o socio de despacho / consultora. Le importa que su equipo hable con clientes internacionales sin hacer el ridículo. Compra rigor, no "clases".
|
||||||
|
- *Lucía* — directora comercial / internacional. Necesita resultados medibles en poco tiempo: presentaciones, negociación, llamadas.
|
||||||
|
|
||||||
|
**B2C**
|
||||||
|
- Adultos 25-45 que necesitan el inglés para el trabajo o para un examen (Cambridge, Aptis, IELTS) y han fracasado con apps.
|
||||||
|
|
||||||
|
## Cómo habla
|
||||||
|
|
||||||
|
- **Tono:** profesional, seguro, directo. Cercano sin ser coloquial. Nunca infantil, nunca grito de descuento.
|
||||||
|
- **Postura:** "formación profesional estructurada", no "aprende inglés fácil". Vendemos **programas**, no clases sueltas.
|
||||||
|
- **Español de España**, tú o usted según el público: *tú* en B2C, *tú* en B2B cuando hablamos a la persona, *usted* nunca.
|
||||||
|
- **Frases cortas.** Un dato concreto vale más que un adjetivo.
|
||||||
|
- **Inglés en el copy:** solo nombres propios de exámenes o términos sin traducción natural. Nada de "boost", "unlock", "level up".
|
||||||
|
|
||||||
|
### Argumentos que funcionan
|
||||||
|
1. Profesor fijo + 60 minutos: progreso real, no mantenimiento.
|
||||||
|
2. FUNDAE gestionado por nosotros: coste cero o casi cero para la empresa.
|
||||||
|
3. Programas por sector: tu equipo aprende el inglés de su trabajo.
|
||||||
|
4. Presencial en Madrid y Málaga + online: híbrido de verdad.
|
||||||
|
5. Informes de asistencia y progreso para RRHH.
|
||||||
|
|
||||||
|
### Objeciones a desactivar
|
||||||
|
- "Ya usamos una app" → la app mantiene, no avanza.
|
||||||
|
- "Es caro" → FUNDAE; coste por empleado y mes.
|
||||||
|
- "No tenemos tiempo" → sesiones in-company en horario laboral.
|
||||||
|
|
||||||
|
## Prohibido
|
||||||
|
- Prometer niveles o plazos ("B2 en 3 meses").
|
||||||
|
- Comparar nombrando a la competencia (ni Twenix ni ninguna).
|
||||||
|
- Descuentos agresivos, cuentas atrás, "última oportunidad".
|
||||||
|
- Emojis en creatividades de imagen. En copy, como mucho uno al inicio y solo en B2C.
|
||||||
|
- Fotografía de stock genérica de "gente feliz con portátil" con aspecto de banco de imágenes.
|
||||||
|
- Testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.
|
||||||
|
- Deformar el logo, recolorearlo o recrearlo. Se coloca el archivo oficial.
|
||||||
|
|
||||||
|
## Identidad visual
|
||||||
|
|
||||||
|
- **Paleta exacta y única:** Azul principal `#2A4D9C` · Amarillo `#FBC02D` · Negro `#000000` · Blanco `#FFFFFF`. Ningún otro color.
|
||||||
|
- **Uso de superficies:** azul y blanco como base. Amarillo solo para resaltar una palabra, un dato o el CTA. Negro reservado a mensajes de formalidad o urgencia (FUNDAE que caduca, manifiesto).
|
||||||
|
- **Tipografía:** Poppins. Extrabold en titulares, Regular en cuerpo. Nunca otra fuente.
|
||||||
|
- **Logo:** lockup oficial *Blue Parrot* + *SCHOOL* en amarillo; versión con filete blanco sobre fondos azul o negro. Área de protección respetada, esquina inferior o superior, nunca centrado ni dominando.
|
||||||
|
- **Estilo de imagen:** fotografía real de aula, oficina o reunión con luz natural; personas adultas de 25-50 en contexto de trabajo; sin poses de catálogo. Si es ilustración o composición gráfica, geometría limpia, mucho aire, una sola idea.
|
||||||
|
- **La imagen tiene que leerse como clase de idioma en 1,5 s.** Siempre un elemento que lo diga sin texto: pizarra con esquemas, libro o cuaderno de ejercicios abierto, auriculares en una sesión online, aula con sillas. Y los roles claros: el profesor de pie, señalando, o con la pizarra detrás; el alumno sentado, escuchando o escribiendo. Nada que parezca firma de documentos, entrevista de trabajo, reunión de banco o consulta.
|
||||||
|
- **Una persona basta** si el mensaje es individual (adulto en sesión online con auriculares y portátil, mirando a cámara o a la pantalla). Dos personas solo si el mensaje es la relación profesor-alumno. Nunca grupos genéricos.
|
||||||
|
|
||||||
|
## Audiencia por campaña
|
||||||
|
|
||||||
|
El brief trae `audience`. Si no viene, se deduce del nombre de campaña:
|
||||||
|
- **B2C** (`Matrículas`, `Intensivos`, `Cambridge`, `Aptis`, `Adultos`): dolor personal, apps que no avanzan, examen, horario. Habla de "tu inglés".
|
||||||
|
- **B2B** (`Empresas`, `FUNDAE`, `In-company`, `Equipos`, nombres de sector): coste bonificado, programas por sector, informes para RRHH, el equipo que habla con clientes. Habla de "tu equipo".
|
||||||
|
Si hay duda, se pregunta en `notes` y se genera en B2C.
|
||||||
|
- **Texto en imagen:** máximo 2 niveles (titular + apoyo). El titular no supera 8 palabras. Nada de texto tocando bordes: margen mínimo 8 % del lado corto.
|
||||||
|
|
||||||
|
## Ejemplos de hooks que han funcionado (referencia de registro, no para copiar)
|
||||||
|
- "Tu equipo no necesita más inglés. Necesita el inglés de su trabajo."
|
||||||
|
- "FUNDAE lo paga. Nosotros lo gestionamos."
|
||||||
|
- "Un profesor. Sesenta minutos. Cada semana. Así se avanza."
|
||||||
|
- "El inglés legal no se aprende en una app."
|
||||||
33
skills/formats/carrusel.md
Normal file
33
skills/formats/carrusel.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Skill de formato · Carrusel RRSS (slides 4:5)
|
||||||
|
|
||||||
|
> Carrusel orgánico: **3–5 slides** en **4:5**. Canal `rrss` → Pipeline (`slide_paths` + cover = slide 1).
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo del carrusel",
|
||||||
|
"hook": "titular del slide 1, máx. 8 palabras",
|
||||||
|
"support": "subtítulo slide 1 o null",
|
||||||
|
"caption": "caption del post carrusel completo",
|
||||||
|
"cta": "CTA final (slide último / caption)",
|
||||||
|
"slides": [
|
||||||
|
{
|
||||||
|
"role": "hook",
|
||||||
|
"hook": "texto slide",
|
||||||
|
"support": null,
|
||||||
|
"image_prompt": "prompt EN INGLÉS 4:5 para este slide",
|
||||||
|
"composition_notes": "montaje slide"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"rationale": "por qué esta secuencia"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Slide 1 = gancho; medios = valor; último = CTA / guardado.
|
||||||
|
- Misma paleta y tono en todos los slides.
|
||||||
|
- Cada `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 with negative space for headline`.
|
||||||
|
- Si el calendario ya trae estructura de carrusel, respétala.
|
||||||
95
skills/formats/estatico-4-5.md
Normal file
95
skills/formats/estatico-4-5.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Skill de formato · Estático para Meta Ads (4:5 + 9:16 + 1.91:1)
|
||||||
|
|
||||||
|
> Se aplica junto a la skill del cliente. Define un anuncio estático de imagen para Meta.
|
||||||
|
> **Diseño base siempre 4:5 (1080×1350).** Cada variante debe además adaptarse a **9:16** (Stories/Reels, 1080×1920) y **1.91:1** (feed landscape / right column, 1200×628).
|
||||||
|
|
||||||
|
## Qué entregas por cada variante
|
||||||
|
|
||||||
|
Un objeto JSON con exactamente estos campos:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "nombre corto del ángulo (p. ej. 'objeción tiempo', 'prueba social', 'coste FUNDAE', 'antes/después', 'autoridad')",
|
||||||
|
"hook": "titular que va EN la imagen, máx. 8 palabras",
|
||||||
|
"support": "línea de apoyo en la imagen, máx. 12 palabras, opcional (null si no hace falta)",
|
||||||
|
"headline": "título del anuncio en Meta, máx. 40 caracteres",
|
||||||
|
"primary_text": "texto principal del anuncio, 2-4 frases, máx. 300 caracteres, la primera frase es el gancho",
|
||||||
|
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
|
||||||
|
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre vertical 4:5 y paleta",
|
||||||
|
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
|
||||||
|
"composition_notes": "instrucciones de montaje 4:5 en 3-5 líneas: dónde va la franja o el texto, acento, logo, qué recortar",
|
||||||
|
"composition_notes_by_aspect": {
|
||||||
|
"4:5": "montaje feed vertical (misma idea que composition_notes)",
|
||||||
|
"9:16": "cómo adaptar a Stories/Reels: qué recortar, dónde subir el hook, zona segura superior/inferior de UI",
|
||||||
|
"1.91:1": "cómo adaptar a landscape: recorte horizontal, hook más corto si hace falta, franja lateral o inferior"
|
||||||
|
},
|
||||||
|
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
|
||||||
|
|
||||||
|
## Entrega multi-formato (obligatorio)
|
||||||
|
|
||||||
|
Por cada variante, el pipeline genera **tres piezas finales** a partir de la misma foto Freepik + hook/support:
|
||||||
|
|
||||||
|
| Ratio | Uso Meta | Tamaño | Campo de salida |
|
||||||
|
|-------|----------|--------|-----------------|
|
||||||
|
| **4:5** | Feed IG/FB (principal) | 1080×1350 | `asset_url` |
|
||||||
|
| **9:16** | Stories / Reels / Advantage+ vertical | 1080×1920 | `meta.stories_asset_url` |
|
||||||
|
| **1.91:1** | Feed landscape / right column | 1200×628 | `meta.landscape_asset_url` |
|
||||||
|
|
||||||
|
- Una sola generación de imagen Freepik en **4:5** con aire para titular.
|
||||||
|
- Canva (WF5) compone el 4:5 y **adapta** tipografía/recortes a 9:16 y 1.91:1 (mismas plantillas de marca o las keys `stories_9_16` / `landscape_1_91`).
|
||||||
|
- No inventes tres fotos distintas: es la misma creatividad en tres placements.
|
||||||
|
|
||||||
|
## Reglas de ángulo
|
||||||
|
|
||||||
|
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
|
||||||
|
- Prioriza ángulos que aún no se hayan probado en el cliente (mira `top_creatives` del contexto: lo que ya funciona no se repite, se complementa).
|
||||||
|
- Rotación recomendada: dolor → solución → prueba → objeción → oferta. Si la fatigada era "oferta", el relevo vuelve a "dolor" o "prueba".
|
||||||
|
|
||||||
|
## Reglas de hook (titular en imagen)
|
||||||
|
|
||||||
|
- Máximo 8 palabras. Si necesita más, no es un hook.
|
||||||
|
- Una sola idea. Un verbo o un dato.
|
||||||
|
- Sin signos de exclamación. Sin mayúsculas completas.
|
||||||
|
- Se lee en 1,5 segundos en móvil con el pulgar en movimiento.
|
||||||
|
- Prohibido empezar por "¿Sabías que", "Descubre", "Imagina".
|
||||||
|
- En **1.91:1** el hook puede acortarse a 5–6 palabras si no cabe con buena jerarquía.
|
||||||
|
|
||||||
|
## Reglas de copy (primary_text)
|
||||||
|
|
||||||
|
- Frase 1: repite o amplía el hook, nunca lo contradice.
|
||||||
|
- Frase 2-3: un beneficio concreto o un dato.
|
||||||
|
- Frase final: el CTA en palabras, coherente con el botón.
|
||||||
|
- Sin hashtags. Sin enlaces en el texto.
|
||||||
|
|
||||||
|
## Reglas de imagen (image_prompt)
|
||||||
|
|
||||||
|
- Siempre en inglés, 40-80 palabras.
|
||||||
|
- Describe: sujeto, acción, entorno, luz, encuadre (**vertical 4:5**, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
|
||||||
|
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
|
||||||
|
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
|
||||||
|
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
|
||||||
|
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
|
||||||
|
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
|
||||||
|
- Una generación por variante. No pidas variaciones ni tres prompts distintos por ratio.
|
||||||
|
|
||||||
|
## Reglas de layout
|
||||||
|
|
||||||
|
- Zona segura 4:5: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
|
||||||
|
- 9:16: deja aire extra arriba/abajo para UI de Stories; el hook puede subir hacia el tercio superior seguro.
|
||||||
|
- 1.91:1: evita texto en los extremos laterales; prioriza franja inferior o bloque lateral corto.
|
||||||
|
- Jerarquía: hook ≥ 2× el tamaño del support.
|
||||||
|
- Contraste mínimo 4,5:1 entre texto y fondo.
|
||||||
|
- El logo ocupa como máximo el 12 % del ancho (en landscape, ≤ 10 %).
|
||||||
|
|
||||||
|
## Autocheck antes de entregar
|
||||||
|
|
||||||
|
- [ ] ¿Las variantes tienen ángulos distintos entre sí y del `avoid_angles`?
|
||||||
|
- [ ] ¿Cada hook tiene ≤ 8 palabras?
|
||||||
|
- [ ] ¿El primary_text cabe en 300 caracteres?
|
||||||
|
- [ ] ¿El image_prompt no pide texto ni logo y es 4:5?
|
||||||
|
- [ ] ¿Hay `composition_notes_by_aspect` para 4:5, 9:16 y 1.91:1?
|
||||||
|
- [ ] ¿Se respeta la lista de prohibiciones de la skill del cliente?
|
||||||
95
skills/formats/estatico.md
Normal file
95
skills/formats/estatico.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Skill de formato · Estático para Meta Ads (4:5 + 9:16 + 1.91:1)
|
||||||
|
|
||||||
|
> Se aplica junto a la skill del cliente. Define un anuncio estático de imagen para Meta.
|
||||||
|
> **Diseño base siempre 4:5 (1080×1350).** Cada variante debe además adaptarse a **9:16** (Stories/Reels, 1080×1920) y **1.91:1** (feed landscape / right column, 1200×628).
|
||||||
|
|
||||||
|
## Qué entregas por cada variante
|
||||||
|
|
||||||
|
Un objeto JSON con exactamente estos campos:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "nombre corto del ángulo (p. ej. 'objeción tiempo', 'prueba social', 'coste FUNDAE', 'antes/después', 'autoridad')",
|
||||||
|
"hook": "titular que va EN la imagen, máx. 8 palabras",
|
||||||
|
"support": "línea de apoyo en la imagen, máx. 12 palabras, opcional (null si no hace falta)",
|
||||||
|
"headline": "título del anuncio en Meta, máx. 40 caracteres",
|
||||||
|
"primary_text": "texto principal del anuncio, 2-4 frases, máx. 300 caracteres, la primera frase es el gancho",
|
||||||
|
"cta": "uno de: 'Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'",
|
||||||
|
"image_prompt": "prompt en inglés para el generador de imagen, SIN texto ni logo, describiendo escena, personas, luz, encuadre vertical 4:5 y paleta",
|
||||||
|
"layout": "una de: 'foto-con-franja' (foto + franja de color con titular), 'tipografico' (solo color y texto), 'dato-grande' (una cifra enorme + contexto)",
|
||||||
|
"composition_notes": "instrucciones de montaje 4:5 en 3-5 líneas: dónde va la franja o el texto, acento, logo, qué recortar",
|
||||||
|
"composition_notes_by_aspect": {
|
||||||
|
"4:5": "montaje feed vertical (misma idea que composition_notes)",
|
||||||
|
"9:16": "cómo adaptar a Stories/Reels: qué recortar, dónde subir el hook, zona segura superior/inferior de UI",
|
||||||
|
"1.91:1": "cómo adaptar a landscape: recorte horizontal, hook más corto si hace falta, franja lateral o inferior"
|
||||||
|
},
|
||||||
|
"rationale": "una frase: por qué este ángulo puede relevar a la fatigada"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Y fuera de las variantes, en `notes`, el diagnóstico de la fatigada en 2-3 frases (qué métrica se movió, si es mensaje o saturación) y cualquier dato que faltara para decidir mejor.
|
||||||
|
|
||||||
|
## Entrega multi-formato (obligatorio)
|
||||||
|
|
||||||
|
Por cada variante, el pipeline genera **tres piezas finales** a partir de la misma foto Freepik + hook/support:
|
||||||
|
|
||||||
|
| Ratio | Uso Meta | Tamaño | Campo de salida |
|
||||||
|
|-------|----------|--------|-----------------|
|
||||||
|
| **4:5** | Feed IG/FB (principal) | 1080×1350 | `asset_url` |
|
||||||
|
| **9:16** | Stories / Reels / Advantage+ vertical | 1080×1920 | `meta.stories_asset_url` |
|
||||||
|
| **1.91:1** | Feed landscape / right column | 1200×628 | `meta.landscape_asset_url` |
|
||||||
|
|
||||||
|
- Una sola generación de imagen Freepik en **4:5** con aire para titular.
|
||||||
|
- Canva (WF5) compone el 4:5 y **adapta** tipografía/recortes a 9:16 y 1.91:1 (mismas plantillas de marca o las keys `stories_9_16` / `landscape_1_91`).
|
||||||
|
- No inventes tres fotos distintas: es la misma creatividad en tres placements.
|
||||||
|
|
||||||
|
## Reglas de ángulo
|
||||||
|
|
||||||
|
- Cada variante ataca un **ángulo distinto** entre sí y distinto del de la creatividad fatigada (viene en `avoid_angles`).
|
||||||
|
- Prioriza ángulos que aún no se hayan probado en el cliente (mira `top_creatives` del contexto: lo que ya funciona no se repite, se complementa).
|
||||||
|
- Rotación recomendada: dolor → solución → prueba → objeción → oferta. Si la fatigada era "oferta", el relevo vuelve a "dolor" o "prueba".
|
||||||
|
|
||||||
|
## Reglas de hook (titular en imagen)
|
||||||
|
|
||||||
|
- Máximo 8 palabras. Si necesita más, no es un hook.
|
||||||
|
- Una sola idea. Un verbo o un dato.
|
||||||
|
- Sin signos de exclamación. Sin mayúsculas completas.
|
||||||
|
- Se lee en 1,5 segundos en móvil con el pulgar en movimiento.
|
||||||
|
- Prohibido empezar por "¿Sabías que", "Descubre", "Imagina".
|
||||||
|
- En **1.91:1** el hook puede acortarse a 5–6 palabras si no cabe con buena jerarquía.
|
||||||
|
|
||||||
|
## Reglas de copy (primary_text)
|
||||||
|
|
||||||
|
- Frase 1: repite o amplía el hook, nunca lo contradice.
|
||||||
|
- Frase 2-3: un beneficio concreto o un dato.
|
||||||
|
- Frase final: el CTA en palabras, coherente con el botón.
|
||||||
|
- Sin hashtags. Sin enlaces en el texto.
|
||||||
|
|
||||||
|
## Reglas de imagen (image_prompt)
|
||||||
|
|
||||||
|
- Siempre en inglés, 40-80 palabras.
|
||||||
|
- Describe: sujeto, acción, entorno, luz, encuadre (**vertical 4:5**, espacio libre arriba o abajo para texto), paleta en términos de la skill del cliente.
|
||||||
|
- **La escena tiene que contar el servicio sin texto.** Incluye siempre un objeto o gesto que identifique la actividad (lo que diga la skill del cliente) y deja claros los roles si hay dos personas. Evita escenas ambiguas que puedan leerse como otra cosa: firma de papeles, entrevista, reunión de banco.
|
||||||
|
- Pide explícitamente el aire para el titular con una fracción: `the upper third of the frame is empty wall or sky`. "Negative space" a secas no basta.
|
||||||
|
- Prefiere una persona a dos cuando el mensaje es individual; nunca más de dos.
|
||||||
|
- Termina siempre con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
|
||||||
|
- Nunca pidas personas reconocibles, marcas de terceros, ni niños.
|
||||||
|
- Una generación por variante. No pidas variaciones ni tres prompts distintos por ratio.
|
||||||
|
|
||||||
|
## Reglas de layout
|
||||||
|
|
||||||
|
- Zona segura 4:5: 8 % de margen en todos los lados. Texto nunca en el 15 % superior ni inferior (lo tapan la interfaz y el CTA de Meta).
|
||||||
|
- 9:16: deja aire extra arriba/abajo para UI de Stories; el hook puede subir hacia el tercio superior seguro.
|
||||||
|
- 1.91:1: evita texto en los extremos laterales; prioriza franja inferior o bloque lateral corto.
|
||||||
|
- Jerarquía: hook ≥ 2× el tamaño del support.
|
||||||
|
- Contraste mínimo 4,5:1 entre texto y fondo.
|
||||||
|
- El logo ocupa como máximo el 12 % del ancho (en landscape, ≤ 10 %).
|
||||||
|
|
||||||
|
## Autocheck antes de entregar
|
||||||
|
|
||||||
|
- [ ] ¿Las variantes tienen ángulos distintos entre sí y del `avoid_angles`?
|
||||||
|
- [ ] ¿Cada hook tiene ≤ 8 palabras?
|
||||||
|
- [ ] ¿El primary_text cabe en 300 caracteres?
|
||||||
|
- [ ] ¿El image_prompt no pide texto ni logo y es 4:5?
|
||||||
|
- [ ] ¿Hay `composition_notes_by_aspect` para 4:5, 9:16 y 1.91:1?
|
||||||
|
- [ ] ¿Se respeta la lista de prohibiciones de la skill del cliente?
|
||||||
32
skills/formats/post.md
Normal file
32
skills/formats/post.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Skill de formato · Post RRSS (feed IG/FB 4:5)
|
||||||
|
|
||||||
|
> Se aplica junto a la skill del cliente (brandbook). Produce una pieza **orgánica** para el Pipeline RRSS, no un anuncio Meta Ads.
|
||||||
|
> Diseño base **4:5 (1080×1350)**. Una sola pieza feed (no hace falta landscape Ads).
|
||||||
|
|
||||||
|
## Contexto de entrada
|
||||||
|
|
||||||
|
El brief trae `channel: "rrss"`, pieza de calendario (`hook`, `body`, `cta`, `hashtags`, `pillar`), brandbook y, si existe, histórico de pipeline / posts orgánicos. **Respeta el calendario**: no inventes otro tema; mejora y visualiza lo planificado.
|
||||||
|
|
||||||
|
## Qué entregas por cada variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto alineado al pilar del calendario",
|
||||||
|
"hook": "titular EN la imagen, máx. 8 palabras",
|
||||||
|
"support": "línea de apoyo en imagen, máx. 12 palabras, o null",
|
||||||
|
"caption": "copy del post (hook + cuerpo + CTA + hashtags útiles), tono de marca, máx. 2200 caracteres",
|
||||||
|
"cta": "CTA suave orgánico (p. ej. 'Guarda este post', 'Escríbenos', 'Link en bio')",
|
||||||
|
"image_prompt": "prompt EN INGLÉS para Freepik, sin texto ni logo, vertical 4:5, aire para titular",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "montaje 4:5 en 3-5 líneas",
|
||||||
|
"rationale": "por qué encaja con el calendario y el ADN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Prioriza el copy del calendario; puedes pulirlo, no cambiar de pilar.
|
||||||
|
- Sin claims prohibidos del brandbook. Sin emojis si la política lo prohíbe.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
|
||||||
|
- Hashtags: solo si el calendario o la marca los usan; máx. 5–8, relevantes.
|
||||||
|
- Write-back: n8n guarda en `creative_pipeline` (cover + caption), no en `creatives` Ads.
|
||||||
24
skills/formats/reel.md
Normal file
24
skills/formats/reel.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Skill de formato · Reel RRSS (cover 9:16)
|
||||||
|
|
||||||
|
> Cover/keyframe de Reel **9:16**. El motion se produce aparte; aquí entregas imagen + caption del Reel.
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto",
|
||||||
|
"hook": "texto en cover, máx. 7 palabras",
|
||||||
|
"support": "subtítulo o null",
|
||||||
|
"caption": "caption del Reel (gancho + valor + CTA + hashtags), tono de marca",
|
||||||
|
"cta": "CTA orgánico",
|
||||||
|
"image_prompt": "prompt EN INGLÉS, vertical 9:16, escena con movimiento implícito / freeze frame potente",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "cover 9:16 legible en feed de Reels",
|
||||||
|
"rationale": "encaje con calendario"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- El cover debe leerse parado (scroll). Misma disciplina de marca que posts.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 cinematic still with negative space for a short hook`.
|
||||||
25
skills/formats/story.md
Normal file
25
skills/formats/story.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Skill de formato · Stories RRSS (9:16)
|
||||||
|
|
||||||
|
> Pieza orgánica vertical **9:16 (1080×1920)** para Instagram/Facebook Stories. Canal `rrss` → Pipeline.
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto",
|
||||||
|
"hook": "texto en story, máx. 6 palabras, muy legible",
|
||||||
|
"support": "sticker/pregunta o segunda línea, o null",
|
||||||
|
"caption": "texto opcional de acompañamiento / nota interna (Stories no siempre llevan caption largo)",
|
||||||
|
"cta": "sticker CTA (p. ej. 'Responde', 'Desliza', 'Enlace')",
|
||||||
|
"image_prompt": "prompt EN INGLÉS, vertical 9:16, zona segura superior/inferior para UI de Stories",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "safe zones 9:16, dónde va el hook",
|
||||||
|
"rationale": "encaje con calendario"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Respeta la pieza del calendario (serie, interactiva, promo…).
|
||||||
|
- Aire para stickers y UI nativa. Sin claims prohibidos.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 with safe margins for Instagram Stories UI`.
|
||||||
57
sql/approval_status_unify.sql
Normal file
57
sql/approval_status_unify.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Unifica estados de aprobación Ads (creatives) + RRSS (creative_pipeline).
|
||||||
|
-- Flujo: borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada
|
||||||
|
-- Idempotente.
|
||||||
|
|
||||||
|
-- ─── creative_pipeline (RRSS) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
alter table public.creative_pipeline drop constraint if exists creative_pipeline_status_check;
|
||||||
|
|
||||||
|
update public.creative_pipeline set status = 'borrador' where status in ('propuesta', 'en_diseno');
|
||||||
|
-- revision_interna se usaba como "el cliente decide" → pendiente_aprobacion
|
||||||
|
update public.creative_pipeline set status = 'pendiente_aprobacion' where status = 'revision_interna';
|
||||||
|
update public.creative_pipeline set status = 'aprobada' where status = 'aprobado';
|
||||||
|
update public.creative_pipeline set status = 'rechazada' where status = 'rechazado';
|
||||||
|
update public.creative_pipeline set status = 'publicada' where status = 'publicado';
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
alter column status set default 'borrador';
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add constraint creative_pipeline_status_check
|
||||||
|
check (status in (
|
||||||
|
'borrador',
|
||||||
|
'revision_agencia',
|
||||||
|
'pendiente_aprobacion',
|
||||||
|
'aprobada',
|
||||||
|
'rechazada',
|
||||||
|
'publicada'
|
||||||
|
));
|
||||||
|
|
||||||
|
comment on column public.creative_pipeline.status is
|
||||||
|
'Flujo unificado: borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada';
|
||||||
|
|
||||||
|
-- ─── creatives (Ads) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
alter table public.creatives drop constraint if exists creatives_status_check;
|
||||||
|
|
||||||
|
-- Legacy EN → ES canónico
|
||||||
|
update public.creatives set status = 'borrador' where status = 'draft';
|
||||||
|
update public.creatives set status = 'pendiente_aprobacion' where status = 'in_review';
|
||||||
|
update public.creatives set status = 'aprobada' where status = 'approved';
|
||||||
|
update public.creatives set status = 'rechazada' where status = 'rejected';
|
||||||
|
|
||||||
|
alter table public.creatives
|
||||||
|
add constraint creatives_status_check
|
||||||
|
check (status in (
|
||||||
|
'borrador',
|
||||||
|
'revision_agencia',
|
||||||
|
'pendiente_aprobacion',
|
||||||
|
'aprobada',
|
||||||
|
'rechazada',
|
||||||
|
'publicada',
|
||||||
|
'fatigada',
|
||||||
|
'retirada'
|
||||||
|
));
|
||||||
|
|
||||||
|
comment on column public.creatives.status is
|
||||||
|
'Flujo unificado + extras Ads (fatigada, retirada). revision_agencia = validación interna antes de enviar al cliente.';
|
||||||
35
sql/change_history.sql
Normal file
35
sql/change_history.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Historial de cambios del Centro de Mando (agencia + cliente).
|
||||||
|
-- Idempotente.
|
||||||
|
|
||||||
|
create table if not exists public.change_history (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid references public.tenants(id) on delete set null,
|
||||||
|
tenant_name text,
|
||||||
|
user_id uuid,
|
||||||
|
user_email text not null default 'sistema',
|
||||||
|
user_role text,
|
||||||
|
entity_type text not null,
|
||||||
|
entity_id text,
|
||||||
|
action text not null,
|
||||||
|
summary text not null,
|
||||||
|
detail jsonb not null default '{}'::jsonb,
|
||||||
|
source text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists change_history_created_idx
|
||||||
|
on public.change_history (created_at desc);
|
||||||
|
|
||||||
|
create index if not exists change_history_tenant_created_idx
|
||||||
|
on public.change_history (tenant_id, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists change_history_entity_idx
|
||||||
|
on public.change_history (entity_type, created_at desc);
|
||||||
|
|
||||||
|
comment on table public.change_history is
|
||||||
|
'Auditoría de cambios: usuario, fecha, acción y detalle (Zona de marca, creatividades, etc.)';
|
||||||
|
|
||||||
|
alter table public.change_history enable row level security;
|
||||||
|
|
||||||
|
-- Solo service role escribe/lee vía API (Centro de Mando). Sin policies abiertas.
|
||||||
|
drop policy if exists change_history_agency_read on public.change_history;
|
||||||
104
sql/creative_pipeline.sql
Normal file
104
sql/creative_pipeline.sql
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
-- Creative pipeline: producción RRSS separada de `creatives` (Ads Meta).
|
||||||
|
-- Flujo unificado (ver sql/approval_status_unify.sql):
|
||||||
|
-- borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada
|
||||||
|
-- Cliente solo ve desde pendiente_aprobacion. Idempotente.
|
||||||
|
|
||||||
|
create table if not exists public.creative_pipeline (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
|
||||||
|
-- referencia externa estable usada por los scripts de generación (ej. "oct-04")
|
||||||
|
external_ref text,
|
||||||
|
|
||||||
|
title text not null,
|
||||||
|
format text not null default 'post'
|
||||||
|
check (format in ('post', 'story', 'reel', 'carrusel')),
|
||||||
|
|
||||||
|
-- mes/calendario al que pertenece la pieza (agrupación en el board)
|
||||||
|
month text, -- 'YYYY-MM'
|
||||||
|
|
||||||
|
status text not null default 'borrador'
|
||||||
|
check (status in (
|
||||||
|
'borrador', 'revision_agencia', 'pendiente_aprobacion',
|
||||||
|
'aprobada', 'rechazada', 'publicada'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- portada (todas las piezas tienen una)
|
||||||
|
cover_path text,
|
||||||
|
cover_mime text,
|
||||||
|
|
||||||
|
-- piezas carrusel: array ordenado de storage paths de los slides interiores + CTA
|
||||||
|
-- ej. ["tenant/.../slide-2.jpg", "tenant/.../slide-3.jpg", ...]
|
||||||
|
slide_paths jsonb not null default '[]'::jsonb,
|
||||||
|
|
||||||
|
caption text,
|
||||||
|
notes text,
|
||||||
|
|
||||||
|
created_by uuid references auth.users(id),
|
||||||
|
approved_by uuid references auth.users(id),
|
||||||
|
approved_at timestamptz,
|
||||||
|
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists creative_pipeline_tenant_ref_idx
|
||||||
|
on public.creative_pipeline (tenant_id, external_ref)
|
||||||
|
where external_ref is not null;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint where conname = 'creative_pipeline_tenant_ref_uniq'
|
||||||
|
) then
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add constraint creative_pipeline_tenant_ref_uniq unique (tenant_id, external_ref);
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_tenant_month_idx
|
||||||
|
on public.creative_pipeline (tenant_id, month, status);
|
||||||
|
|
||||||
|
alter table public.creative_pipeline enable row level security;
|
||||||
|
|
||||||
|
-- Lectura: cliente ve las piezas de su propio tenant (incluye revisión interna,
|
||||||
|
-- ya que este panel es también para el cliente, no solo para el equipo).
|
||||||
|
create policy creative_pipeline_tenant_select on public.creative_pipeline
|
||||||
|
for select using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Alta/edición de contenido vía API del equipo (service role) — bloqueado para
|
||||||
|
-- clientes directos por RLS; el endpoint PATCH permite al cliente mover el
|
||||||
|
-- estado a 'aprobado'/'rechazado' únicamente mediante el mismo filtro de tenant.
|
||||||
|
create policy creative_pipeline_tenant_insert on public.creative_pipeline
|
||||||
|
for insert with check (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy creative_pipeline_tenant_update on public.creative_pipeline
|
||||||
|
for update using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy creative_pipeline_tenant_delete on public.creative_pipeline
|
||||||
|
for delete using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.creative_pipeline is
|
||||||
|
'Pipeline interno de producción de creatividades (RRSS), separado de creatives (ads). Mismo bucket tenant-assets, prefijo pipeline/.';
|
||||||
|
|
||||||
|
-- Storage: reutiliza el bucket privado `tenant-assets` (ver src/lib/assets.ts),
|
||||||
|
-- path = {tenant_id}/pipeline/{id}/cover.jpg y {tenant_id}/pipeline/{id}/slide-N.jpg
|
||||||
12
sql/creative_pipeline_calendar_link.sql
Normal file
12
sql/creative_pipeline_calendar_link.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Link pipeline creatives to RRSS calendar pieces (documents content.pieces[].id).
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists calendar_piece_id text;
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_calendar_piece_idx
|
||||||
|
on public.creative_pipeline (tenant_id, calendar_piece_id)
|
||||||
|
where calendar_piece_id is not null;
|
||||||
|
|
||||||
|
comment on column public.creative_pipeline.calendar_piece_id is
|
||||||
|
'Stable id of the RRSS calendar piece (documents type=calendar → pieces[].id). Agency links pipeline ↔ editorial plan.';
|
||||||
52
sql/creative_pipeline_schedule.sql
Normal file
52
sql/creative_pipeline_schedule.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- Agency-only scheduling for editorial creativities.
|
||||||
|
-- aprobada + scheduled_at → cron publishes IG/FB; status becomes publicada.
|
||||||
|
-- Idempotent. (Estados canónicos: sql/approval_status_unify.sql)
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists scheduled_at timestamptz;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists channels text[] not null default array['instagram','facebook']::text[];
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_status text not null default 'idle';
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint where conname = 'creative_pipeline_publish_status_chk'
|
||||||
|
) then
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add constraint creative_pipeline_publish_status_chk
|
||||||
|
check (publish_status in ('idle', 'queued', 'publishing', 'published', 'failed'));
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_attempts int not null default 0;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists published_at timestamptz;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists meta_ig_media_id text;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists meta_fb_post_id text;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_error text;
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_due_publish_idx
|
||||||
|
on public.creative_pipeline (tenant_id, scheduled_at)
|
||||||
|
where status = 'aprobada' and publish_status in ('idle', 'queued', 'failed');
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_scheduled_at_idx
|
||||||
|
on public.creative_pipeline (tenant_id, scheduled_at)
|
||||||
|
where scheduled_at is not null;
|
||||||
|
|
||||||
|
comment on column public.creative_pipeline.scheduled_at is
|
||||||
|
'Agency-only: when to publish to Meta (IG/FB). Null = not scheduled.';
|
||||||
|
comment on column public.creative_pipeline.channels is
|
||||||
|
'Agency-only: subset of {instagram,facebook} to publish at scheduled_at.';
|
||||||
|
comment on column public.creative_pipeline.publish_status is
|
||||||
|
'idle|queued|publishing|published|failed — worker state separate from editorial status.';
|
||||||
209
sql/creatives_module_migration.sql
Normal file
209
sql/creatives_module_migration.sql
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
-- Migration: Module Creatives (CREATIVOS_BRIEF.md)
|
||||||
|
-- Multi-tenant schema using tenant_id references public.tenants(id)
|
||||||
|
|
||||||
|
-- 1. Extend public.tenants
|
||||||
|
alter table public.tenants
|
||||||
|
add column if not exists meta_ad_account_id text,
|
||||||
|
add column if not exists slack_channel_id text,
|
||||||
|
add column if not exists drive_folder_id text,
|
||||||
|
add column if not exists creatives_enabled boolean not null default false;
|
||||||
|
|
||||||
|
-- 2. Adapt/Create public.creatives
|
||||||
|
-- First, adapt existing table or create if not exists
|
||||||
|
create table if not exists public.creatives (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
campaign_name text,
|
||||||
|
adset_id text,
|
||||||
|
meta_ad_id text unique,
|
||||||
|
format text not null check (format in ('estatico','carrusel','video')),
|
||||||
|
angle text,
|
||||||
|
hook text,
|
||||||
|
primary_text text,
|
||||||
|
headline text,
|
||||||
|
cta text,
|
||||||
|
asset_url text,
|
||||||
|
thumbnail_url text,
|
||||||
|
status text not null default 'borrador'
|
||||||
|
check (status in ('borrador','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada')),
|
||||||
|
visibility text not null default 'agency' check (visibility in ('client','agency')),
|
||||||
|
parent_id uuid references public.creatives(id) on delete set null,
|
||||||
|
generation_request_id uuid,
|
||||||
|
created_by text not null default 'agency',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ensure all columns exist and checks are aligned if table already existed
|
||||||
|
alter table public.creatives add column if not exists campaign_name text;
|
||||||
|
alter table public.creatives add column if not exists adset_id text;
|
||||||
|
alter table public.creatives add column if not exists meta_ad_id text;
|
||||||
|
alter table public.creatives add column if not exists angle text;
|
||||||
|
alter table public.creatives add column if not exists hook text;
|
||||||
|
alter table public.creatives add column if not exists asset_url text;
|
||||||
|
alter table public.creatives add column if not exists thumbnail_url text;
|
||||||
|
alter table public.creatives add column if not exists visibility text not null default 'agency';
|
||||||
|
alter table public.creatives add column if not exists parent_id uuid references public.creatives(id) on delete set null;
|
||||||
|
alter table public.creatives add column if not exists generation_request_id uuid;
|
||||||
|
|
||||||
|
-- Change created_by to text if it was uuid
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (
|
||||||
|
select 1 from information_schema.columns
|
||||||
|
where table_schema = 'public' and table_name = 'creatives' and column_name = 'created_by' and data_type = 'uuid'
|
||||||
|
) then
|
||||||
|
alter table public.creatives drop constraint if exists creatives_created_by_fkey;
|
||||||
|
alter table public.creatives alter column created_by type text using created_by::text;
|
||||||
|
alter table public.creatives alter column created_by set default 'agency';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Drop obsolete constraints if present
|
||||||
|
alter table public.creatives drop constraint if exists creatives_status_check;
|
||||||
|
alter table public.creatives drop constraint if exists creatives_format_check;
|
||||||
|
alter table public.creatives drop constraint if exists creatives_visibility_check;
|
||||||
|
alter table public.creatives drop constraint if exists creatives_meta_ad_id_key;
|
||||||
|
|
||||||
|
-- Ensure meta_ad_id unique constraint
|
||||||
|
alter table public.creatives add constraint creatives_meta_ad_id_key unique (meta_ad_id);
|
||||||
|
|
||||||
|
-- Enforce check constraints from brief
|
||||||
|
alter table public.creatives add constraint creatives_status_check
|
||||||
|
check (status in ('borrador','revision_agencia','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada'));
|
||||||
|
|
||||||
|
alter table public.creatives add constraint creatives_format_check
|
||||||
|
check (format in ('estatico','carrusel','video','image','carousel','reel','story','rsa','pmax','pmax_landscape','pmax_square','pmax_portrait','pmax_vertical','pmax_logo','youtube','other'));
|
||||||
|
|
||||||
|
alter table public.creatives add constraint creatives_visibility_check
|
||||||
|
check (visibility in ('client','agency'));
|
||||||
|
|
||||||
|
create index if not exists creatives_tenant_status_idx on public.creatives (tenant_id, status);
|
||||||
|
create index if not exists creatives_meta_ad_id_idx on public.creatives (meta_ad_id);
|
||||||
|
|
||||||
|
-- 3. creative_metrics_daily
|
||||||
|
create table if not exists public.creative_metrics_daily (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
creative_id uuid not null references public.creatives(id) on delete cascade,
|
||||||
|
meta_ad_id text not null,
|
||||||
|
date date not null,
|
||||||
|
impressions integer default 0,
|
||||||
|
reach integer default 0,
|
||||||
|
frequency numeric(6,2) default 0,
|
||||||
|
clicks integer default 0,
|
||||||
|
ctr numeric(6,3) default 0,
|
||||||
|
cpm numeric(10,2) default 0,
|
||||||
|
cpc numeric(10,2) default 0,
|
||||||
|
spend numeric(10,2) default 0,
|
||||||
|
results integer default 0,
|
||||||
|
cpa numeric(10,2),
|
||||||
|
raw jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (meta_ad_id, date)
|
||||||
|
);
|
||||||
|
create index if not exists creative_metrics_daily_tenant_date_idx on public.creative_metrics_daily (tenant_id, date);
|
||||||
|
|
||||||
|
-- 4. fatigue_rules
|
||||||
|
create table if not exists public.fatigue_rules (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null unique references public.tenants(id) on delete cascade,
|
||||||
|
lookback_days integer not null default 7,
|
||||||
|
min_impressions integer not null default 2000,
|
||||||
|
max_frequency numeric(4,2) not null default 3.0,
|
||||||
|
ctr_drop_pct numeric(5,2) not null default 30,
|
||||||
|
cpa_rise_pct numeric(5,2) not null default 25,
|
||||||
|
signals_required integer not null default 2,
|
||||||
|
auto_request_relief boolean not null default true,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. generation_requests
|
||||||
|
create table if not exists public.generation_requests (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
reason text not null check (reason in ('fatiga','calendario','nueva_campana','manual')),
|
||||||
|
source_creative_id uuid references public.creatives(id) on delete set null,
|
||||||
|
brief jsonb not null,
|
||||||
|
status text not null default 'pendiente'
|
||||||
|
check (status in ('pendiente','en_proceso','completada','fallida')),
|
||||||
|
result jsonb,
|
||||||
|
cost_estimate jsonb,
|
||||||
|
error text,
|
||||||
|
requested_at timestamptz not null default now(),
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
create index if not exists generation_requests_tenant_status_idx on public.generation_requests (tenant_id, status);
|
||||||
|
|
||||||
|
-- Foreign key linking creatives.generation_request_id to generation_requests.id
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from information_schema.table_constraints
|
||||||
|
where constraint_name = 'creatives_generation_request_id_fkey'
|
||||||
|
) then
|
||||||
|
alter table public.creatives
|
||||||
|
add constraint creatives_generation_request_id_fkey
|
||||||
|
foreign key (generation_request_id) references public.generation_requests(id) on delete set null;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- 6. approvals
|
||||||
|
create table if not exists public.approvals (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
creative_id uuid not null references public.creatives(id) on delete cascade,
|
||||||
|
user_id uuid references public.users(id) on delete set null,
|
||||||
|
actor_role text not null check (actor_role in ('client','agency')),
|
||||||
|
decision text not null check (decision in ('aprobada','rechazada')),
|
||||||
|
comment text,
|
||||||
|
channel text not null default 'app' check (channel in ('app','slack')),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists approvals_tenant_creative_idx on public.approvals (tenant_id, creative_id);
|
||||||
|
|
||||||
|
-- 7. creative_events
|
||||||
|
create table if not exists public.creative_events (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
creative_id uuid references public.creatives(id) on delete cascade,
|
||||||
|
event_type text not null,
|
||||||
|
actor text not null,
|
||||||
|
from_status text,
|
||||||
|
to_status text,
|
||||||
|
payload jsonb,
|
||||||
|
visibility text not null default 'agency' check (visibility in ('client','agency')),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists creative_events_tenant_created_idx on public.creative_events (tenant_id, created_at desc);
|
||||||
|
|
||||||
|
-- 8. Seed Blue Parrot School (bps)
|
||||||
|
insert into public.fatigue_rules (
|
||||||
|
tenant_id,
|
||||||
|
lookback_days,
|
||||||
|
min_impressions,
|
||||||
|
max_frequency,
|
||||||
|
ctr_drop_pct,
|
||||||
|
cpa_rise_pct,
|
||||||
|
signals_required,
|
||||||
|
auto_request_relief
|
||||||
|
)
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
7,
|
||||||
|
2000,
|
||||||
|
3.0,
|
||||||
|
30.0,
|
||||||
|
25.0,
|
||||||
|
2,
|
||||||
|
true
|
||||||
|
from public.tenants
|
||||||
|
where slug = 'bps'
|
||||||
|
on conflict (tenant_id) do update set
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
update public.tenants
|
||||||
|
set
|
||||||
|
creatives_enabled = true,
|
||||||
|
meta_ad_account_id = coalesce(meta_ad_account_id, 'act_1052328112282270')
|
||||||
|
where slug = 'bps';
|
||||||
44
sql/documents_rls.sql
Normal file
44
sql/documents_rls.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
-- Tenant isolation for editorial calendars and other documents.
|
||||||
|
-- Clients only see their own tenant_id; agency uses service role / viaView APIs.
|
||||||
|
|
||||||
|
alter table public.documents enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists documents_tenant_select on public.documents;
|
||||||
|
drop policy if exists documents_tenant_insert on public.documents;
|
||||||
|
drop policy if exists documents_tenant_update on public.documents;
|
||||||
|
drop policy if exists documents_tenant_delete on public.documents;
|
||||||
|
|
||||||
|
create policy documents_tenant_select on public.documents
|
||||||
|
for select using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy documents_tenant_insert on public.documents
|
||||||
|
for insert with check (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy documents_tenant_update on public.documents
|
||||||
|
for update using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy documents_tenant_delete on public.documents
|
||||||
|
for delete using (
|
||||||
|
tenant_id::text = coalesce(
|
||||||
|
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
|
||||||
|
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.documents is
|
||||||
|
'Tenant-scoped docs (type=calendar RRSS plans, etc.). RLS: JWT tenant only; agency writes via service role.';
|
||||||
8
sql/generation_requests_copy_v2.sql
Normal file
8
sql/generation_requests_copy_v2.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Opcional: permite reason 'copy_v2' además de fatiga/calendario/nueva_campana/manual.
|
||||||
|
-- Idempotente. Si no se aplica, el código usa reason='manual' + brief.copy_v2.
|
||||||
|
|
||||||
|
alter table public.generation_requests drop constraint if exists generation_requests_reason_check;
|
||||||
|
|
||||||
|
alter table public.generation_requests
|
||||||
|
add constraint generation_requests_reason_check
|
||||||
|
check (reason in ('fatiga', 'calendario', 'nueva_campana', 'manual', 'copy_v2'));
|
||||||
31
sql/gohighlevel.sql
Normal file
31
sql/gohighlevel.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- GoHighLevel CRM slot per tenant
|
||||||
|
-- Idempotent. PIT stays in integrations.meta (agency-only), never in git.
|
||||||
|
|
||||||
|
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',
|
||||||
|
'gohighlevel',
|
||||||
|
'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, 'gohighlevel', 'disconnected', 'GoHighLevel'
|
||||||
|
from public.tenants t
|
||||||
|
on conflict (tenant_id, provider) do nothing;
|
||||||
@@ -11,3 +11,6 @@ comment on column public.targets.spend_target is
|
|||||||
'Presupuesto ads objetivo del mes (ppto)';
|
'Presupuesto ads objetivo del mes (ppto)';
|
||||||
comment on column public.targets.spend_actual is
|
comment on column public.targets.spend_actual is
|
||||||
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';
|
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';
|
||||||
|
|
||||||
|
create unique index if not exists targets_tenant_month_uidx
|
||||||
|
on public.targets (tenant_id, month);
|
||||||
|
|||||||
32
sql/woocommerce_provider.sql
Normal file
32
sql/woocommerce_provider.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- WooCommerce integration slot (health + future direct order sync)
|
||||||
|
-- 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',
|
||||||
|
'gohighlevel',
|
||||||
|
'klaviyo',
|
||||||
|
'mailchimp',
|
||||||
|
'google_drive',
|
||||||
|
'wordpress',
|
||||||
|
'prestashop',
|
||||||
|
'shopify',
|
||||||
|
'woocommerce',
|
||||||
|
'instagram',
|
||||||
|
'facebook',
|
||||||
|
'tiktok',
|
||||||
|
'linkedin',
|
||||||
|
'superhuman_docs'
|
||||||
|
));
|
||||||
|
|
||||||
|
insert into public.integrations (tenant_id, provider, status, label)
|
||||||
|
select t.id, 'woocommerce', 'disconnected', 'WooCommerce'
|
||||||
|
from public.tenants t
|
||||||
|
on conflict (tenant_id, provider) do nothing;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
buildGoogleCardsFromDaily,
|
buildGoogleCardsFromDaily,
|
||||||
fetchGoogleCreativeCards,
|
fetchGoogleCreativeCards,
|
||||||
} from '@/lib/ad-creatives-google';
|
} from '@/lib/ad-creatives-google';
|
||||||
|
import { filterRowsForTenantBrand } from '@/lib/tenant-brand-aliases';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -80,8 +81,12 @@ export async function GET(req: NextRequest) {
|
|||||||
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
|
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
|
||||||
const selectNoLc =
|
const selectNoLc =
|
||||||
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
|
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
|
||||||
|
const tid = encodeURIComponent(ctx.tenantId);
|
||||||
|
const slugRes = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${tid}&select=slug&limit=1`
|
||||||
|
);
|
||||||
|
const tenantSlug = slugRes.data?.[0]?.slug || null;
|
||||||
if (agency && ctx.viaView) {
|
if (agency && ctx.viaView) {
|
||||||
const tid = encodeURIComponent(ctx.tenantId);
|
|
||||||
let d = await restGet<AdDailyRow[]>(
|
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`
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000`
|
||||||
);
|
);
|
||||||
@@ -92,9 +97,10 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
daily = d.data || [];
|
daily = d.data || [];
|
||||||
} else {
|
} else {
|
||||||
let q = await supabase
|
let q: any = await supabase
|
||||||
.from('ad_daily')
|
.from('ad_daily')
|
||||||
.select(selectWithLc)
|
.select(selectWithLc)
|
||||||
|
.eq('tenant_id', ctx.tenantId)
|
||||||
.gte('date', since)
|
.gte('date', since)
|
||||||
.lte('date', until)
|
.lte('date', until)
|
||||||
.order('date', { ascending: false })
|
.order('date', { ascending: false })
|
||||||
@@ -103,6 +109,7 @@ export async function GET(req: NextRequest) {
|
|||||||
q = await supabase
|
q = await supabase
|
||||||
.from('ad_daily')
|
.from('ad_daily')
|
||||||
.select(selectNoLc)
|
.select(selectNoLc)
|
||||||
|
.eq('tenant_id', ctx.tenantId)
|
||||||
.gte('date', since)
|
.gte('date', since)
|
||||||
.lte('date', until)
|
.lte('date', until)
|
||||||
.order('date', { ascending: false })
|
.order('date', { ascending: false })
|
||||||
@@ -110,6 +117,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
daily = (q.data as AdDailyRow[]) || [];
|
daily = (q.data as AdDailyRow[]) || [];
|
||||||
}
|
}
|
||||||
|
daily = filterRowsForTenantBrand(tenantSlug, daily);
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
let metaAds: AdCreativeCard[] = [];
|
let metaAds: AdCreativeCard[] = [];
|
||||||
@@ -123,7 +131,7 @@ export async function GET(req: NextRequest) {
|
|||||||
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
|
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
|
||||||
if (mode === 'fast') {
|
if (mode === 'fast') {
|
||||||
if (platform === 'meta' || platform === 'all') {
|
if (platform === 'meta' || platform === 'all') {
|
||||||
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
|
metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
|
||||||
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
||||||
}
|
}
|
||||||
if (platform === 'google' || platform === 'all') {
|
if (platform === 'google' || platform === 'all') {
|
||||||
@@ -152,6 +160,7 @@ export async function GET(req: NextRequest) {
|
|||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
limit: 16,
|
limit: 16,
|
||||||
|
tenantSlug,
|
||||||
}),
|
}),
|
||||||
fetchMetaAccountClickSplit({
|
fetchMetaAccountClickSplit({
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
@@ -161,7 +170,7 @@ export async function GET(req: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
metaAds = meta.ads.length
|
metaAds = meta.ads.length
|
||||||
? meta.ads
|
? meta.ads
|
||||||
: buildMetaWarehouseCards(daily, since, until, 16);
|
: buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
|
||||||
metaClickSplit =
|
metaClickSplit =
|
||||||
clickSplit || metaClickSplitFromDaily(daily, since, until);
|
clickSplit || metaClickSplitFromDaily(daily, since, until);
|
||||||
if (
|
if (
|
||||||
@@ -171,7 +180,7 @@ export async function GET(req: NextRequest) {
|
|||||||
warnings.push(meta.warning);
|
warnings.push(meta.warning);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
|
metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
|
||||||
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +192,7 @@ export async function GET(req: NextRequest) {
|
|||||||
dailyRows: daily,
|
dailyRows: daily,
|
||||||
since,
|
since,
|
||||||
until,
|
until,
|
||||||
limit: 24,
|
limit: 80,
|
||||||
});
|
});
|
||||||
googleAds = google.ads.length
|
googleAds = google.ads.length
|
||||||
? google.ads
|
? google.ads
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
|
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client';
|
if (!isAgencyRole(resolveUserRole(user))) {
|
||||||
if (userRole !== 'admin' && userRole !== 'team') {
|
|
||||||
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
|
return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +52,14 @@ export async function POST(req: NextRequest) {
|
|||||||
['search_console', 'Search Console'],
|
['search_console', 'Search Console'],
|
||||||
['tag_manager', 'Tag Manager'],
|
['tag_manager', 'Tag Manager'],
|
||||||
['clientify', 'Clientify'],
|
['clientify', 'Clientify'],
|
||||||
|
['gohighlevel', 'GoHighLevel'],
|
||||||
['klaviyo', 'Klaviyo'],
|
['klaviyo', 'Klaviyo'],
|
||||||
['mailchimp', 'Mailchimp'],
|
['mailchimp', 'Mailchimp'],
|
||||||
['google_drive', 'Google Drive'],
|
['google_drive', 'Google Drive'],
|
||||||
['wordpress', 'WordPress'],
|
['wordpress', 'WordPress'],
|
||||||
['prestashop', 'PrestaShop'],
|
['prestashop', 'PrestaShop'],
|
||||||
['shopify', 'Shopify'],
|
['shopify', 'Shopify'],
|
||||||
|
['woocommerce', 'WooCommerce'],
|
||||||
['instagram', 'Instagram'],
|
['instagram', 'Instagram'],
|
||||||
['facebook', 'Facebook Page'],
|
['facebook', 'Facebook Page'],
|
||||||
['tiktok', 'TikTok'],
|
['tiktok', 'TikTok'],
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
|
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
|
||||||
const enabled =
|
const enabled =
|
||||||
typeof body.enabled === 'boolean' ? body.enabled : undefined;
|
typeof body.enabled === 'boolean' ? body.enabled : undefined;
|
||||||
|
const pit = typeof body.pit === 'string' ? body.pit.trim() : '';
|
||||||
|
|
||||||
if (!tenantId || !provider) {
|
if (!tenantId || !provider) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -193,7 +194,23 @@ export async function PATCH(req: NextRequest) {
|
|||||||
patch.error_message = null;
|
patch.error_message = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!status && enabled === undefined && externalAccountId === undefined) {
|
if (provider === 'gohighlevel' && (pit || externalAccountId !== undefined)) {
|
||||||
|
const cur = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.gohighlevel&select=meta`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
let meta: Record<string, unknown> = {};
|
||||||
|
if (cur.ok) {
|
||||||
|
const rows = (await cur.json()) as { meta?: Record<string, unknown> }[];
|
||||||
|
if (rows[0]?.meta && typeof rows[0].meta === 'object') meta = { ...rows[0].meta };
|
||||||
|
}
|
||||||
|
if (pit) meta.pit = pit;
|
||||||
|
if (externalAccountId) meta.location_id = externalAccountId;
|
||||||
|
patch.meta = meta;
|
||||||
|
if (!label) patch.label = patch.label || 'GoHighLevel';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status && enabled === undefined && externalAccountId === undefined && !pit) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'status, enabled o externalAccountId requeridos' },
|
{ error: 'status, enabled o externalAccountId requeridos' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import {
|
|||||||
supabaseUrl,
|
supabaseUrl,
|
||||||
userRole,
|
userRole,
|
||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
|
import { portfolioSpendWeightedCac } from '@/lib/metrics';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function dayOffset(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
|
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -32,23 +39,33 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const base = supabaseUrl();
|
const base = supabaseUrl();
|
||||||
const [tenantsRes, integRes, alertsRes] = await Promise.all([
|
const since30 = dayOffset(-30);
|
||||||
fetch(
|
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
|
||||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
await Promise.all([
|
||||||
{
|
fetch(
|
||||||
headers,
|
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
||||||
cache: 'no-store',
|
{
|
||||||
}
|
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/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
|
||||||
fetch(
|
{ headers, cache: 'no-store' }
|
||||||
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
|
),
|
||||||
{ 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' }
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${base}/rest/v1/ad_daily?select=tenant_id,spend&date=gte.${since30}&limit=20000`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${base}/rest/v1/deals?select=tenant_id,amount,stage,won_at&stage=eq.won&won_at=gte.${since30}&limit=10000`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
|
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
|
||||||
// Fallback if agency_followed column missing
|
// Fallback if agency_followed column missing
|
||||||
@@ -70,6 +87,34 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
const alerts = alertsRes.ok ? await alertsRes.json() : [];
|
const alerts = alertsRes.ok ? await alertsRes.json() : [];
|
||||||
|
|
||||||
|
const spendByTenant = new Map<string, number>();
|
||||||
|
if (spendRes.ok) {
|
||||||
|
for (const row of (await spendRes.json()) as {
|
||||||
|
tenant_id?: string;
|
||||||
|
spend?: number;
|
||||||
|
}[]) {
|
||||||
|
if (!row.tenant_id) continue;
|
||||||
|
spendByTenant.set(
|
||||||
|
row.tenant_id,
|
||||||
|
(spendByTenant.get(row.tenant_id) || 0) + Number(row.spend || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dealsByTenant = new Map<string, number>();
|
||||||
|
if (dealsRes.ok) {
|
||||||
|
for (const row of (await dealsRes.json()) as {
|
||||||
|
tenant_id?: string;
|
||||||
|
amount?: number;
|
||||||
|
}[]) {
|
||||||
|
if (!row.tenant_id) continue;
|
||||||
|
if (!(Number(row.amount || 0) > 0)) continue;
|
||||||
|
dealsByTenant.set(
|
||||||
|
row.tenant_id,
|
||||||
|
(dealsByTenant.get(row.tenant_id) || 0) + 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Integ = {
|
type Integ = {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -106,11 +151,19 @@ export async function GET() {
|
|||||||
for (const p of INTEGRATION_PROVIDERS) {
|
for (const p of INTEGRATION_PROVIDERS) {
|
||||||
byProvider[p.id] = rows.find((r) => r.provider === p.id);
|
byProvider[p.id] = rows.find((r) => r.provider === p.id);
|
||||||
}
|
}
|
||||||
|
const spend30 = spendByTenant.get(t.id) || 0;
|
||||||
|
const deals30 = dealsByTenant.get(t.id) || 0;
|
||||||
|
const cac30 = deals30 > 0 ? spend30 / deals30 : 0;
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
agency_followed: t.agency_followed !== false,
|
agency_followed: t.agency_followed !== false,
|
||||||
integrations: rows,
|
integrations: rows,
|
||||||
byProvider,
|
byProvider,
|
||||||
|
economics: {
|
||||||
|
spend30,
|
||||||
|
deals30,
|
||||||
|
cac30,
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
connected,
|
connected,
|
||||||
errors,
|
errors,
|
||||||
@@ -129,11 +182,20 @@ export async function GET() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const portfolioCac = portfolioSpendWeightedCac(
|
||||||
|
portfolio.map((p) => ({
|
||||||
|
spend: p.economics.spend30,
|
||||||
|
deals: p.economics.deals30,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
portfolio,
|
portfolio,
|
||||||
alerts,
|
alerts,
|
||||||
providers: INTEGRATION_PROVIDERS,
|
providers: INTEGRATION_PROVIDERS,
|
||||||
schemaReady: !integMissing,
|
schemaReady: !integMissing,
|
||||||
|
portfolioCac30: portfolioCac,
|
||||||
|
economicsWindowDays: 30,
|
||||||
warning: integMissing
|
warning: integMissing
|
||||||
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
209
src/app/api/admin/sync/route.ts
Normal file
209
src/app/api/admin/sync/route.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||||
|
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||||
|
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||||
|
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
|
||||||
|
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||||
|
import {
|
||||||
|
getAgencyOAuthStatus,
|
||||||
|
applyMatches,
|
||||||
|
matchFoldersToTenants,
|
||||||
|
listClientFoldersUnderClientes,
|
||||||
|
} from '@/lib/google-drive';
|
||||||
|
import {
|
||||||
|
syncAllSuperhumanTenants,
|
||||||
|
superhumanConfigured,
|
||||||
|
} from '@/lib/superhuman-docs';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
export type SyncModule =
|
||||||
|
| 'metrics'
|
||||||
|
| 'crm'
|
||||||
|
| 'seguimiento'
|
||||||
|
| 'ads'
|
||||||
|
| 'social'
|
||||||
|
| 'organico'
|
||||||
|
| 'activos'
|
||||||
|
| 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST { module, days?, tenantId? }
|
||||||
|
* Agency-triggered refresh for dashboard modules.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user || !isAgencyRole(userRole(user))) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const module = (body.module || 'all') as SyncModule;
|
||||||
|
const days =
|
||||||
|
typeof body.days === 'number' && body.days > 0 && body.days <= 90
|
||||||
|
? body.days
|
||||||
|
: 14;
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
const tenantId =
|
||||||
|
typeof body.tenantId === 'string'
|
||||||
|
? body.tenantId
|
||||||
|
: ctx.portfolioWide
|
||||||
|
? undefined
|
||||||
|
: ctx.tenantId || undefined;
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {
|
||||||
|
ok: true,
|
||||||
|
module,
|
||||||
|
tenantId: tenantId || null,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const want = (m: SyncModule) => module === 'all' || module === m;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (want('crm') || want('metrics')) {
|
||||||
|
const [clientify, gohighlevel, shopify] = await Promise.all([
|
||||||
|
syncAllClientifyTenants({ tenantId }),
|
||||||
|
syncAllGhlTenants({ tenantId }),
|
||||||
|
syncAllShopifyTenants({ tenantId }),
|
||||||
|
]);
|
||||||
|
out.crm = {
|
||||||
|
results: [
|
||||||
|
...clientify.results,
|
||||||
|
...gohighlevel.results,
|
||||||
|
...shopify.results.map((r) => ({
|
||||||
|
tenantId: r.tenantId,
|
||||||
|
tenantSlug: r.tenantSlug,
|
||||||
|
leads: 0,
|
||||||
|
deals: r.orders,
|
||||||
|
error: r.error,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
clientify,
|
||||||
|
gohighlevel,
|
||||||
|
shopify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.crm = {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : 'crm failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (want('ads') || want('metrics')) {
|
||||||
|
const ads = await syncAdsForTenants({ tenantId, days });
|
||||||
|
out.ads = {
|
||||||
|
ok: true,
|
||||||
|
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
|
||||||
|
withErrors: ads.results.filter((r) => r.errors.length).length,
|
||||||
|
results: ads.results.slice(0, 30),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.ads = {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : 'ads failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (want('organico') || want('metrics')) {
|
||||||
|
const oauth = await getAgencyOAuthStatus();
|
||||||
|
if (!oauth.connected) {
|
||||||
|
out.organico = { ok: false, warning: 'Google agencia no conectado' };
|
||||||
|
} else {
|
||||||
|
out.organico = {
|
||||||
|
ok: true,
|
||||||
|
...(await syncAllConnectedOrganic({ days, tenantId })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.organico = {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : 'organic failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (want('seguimiento')) {
|
||||||
|
if (!superhumanConfigured()) {
|
||||||
|
out.seguimiento = {
|
||||||
|
ok: false,
|
||||||
|
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
|
||||||
|
};
|
||||||
|
} else if (tenantId) {
|
||||||
|
out.seguimiento = {
|
||||||
|
ok: true,
|
||||||
|
...(await syncAllSuperhumanTenants({
|
||||||
|
tenantId,
|
||||||
|
maxEntries: 80,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
out.seguimiento = {
|
||||||
|
ok: true,
|
||||||
|
...(await syncAllSuperhumanTenants({ maxEntries: 80 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.seguimiento = {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : 'seguimiento failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (want('activos')) {
|
||||||
|
const oauth = await getAgencyOAuthStatus();
|
||||||
|
if (!oauth.connected) {
|
||||||
|
out.activos = { ok: false, warning: 'Google Drive no conectado' };
|
||||||
|
} else {
|
||||||
|
let tenants: { id: string; name: string; slug: string }[] = [];
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (headers && base) {
|
||||||
|
const tr = await fetch(
|
||||||
|
`${base}/rest/v1/tenants?select=id,name,slug&agency_followed=neq.false`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (tr.ok) tenants = (await tr.json()) as typeof tenants;
|
||||||
|
}
|
||||||
|
const { folders } = await listClientFoldersUnderClientes();
|
||||||
|
const matches = await matchFoldersToTenants(tenants, folders);
|
||||||
|
const { linked } = await applyMatches(matches);
|
||||||
|
out.activos = {
|
||||||
|
ok: true,
|
||||||
|
matched: matches.filter((m) => m.status === 'matched').length,
|
||||||
|
linked,
|
||||||
|
hint: 'Carpetas Drive re-emparejadas. Recarga Activos para ver archivos.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.activos = {
|
||||||
|
ok: false,
|
||||||
|
error: e instanceof Error ? e.message : 'activos failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (want('social')) {
|
||||||
|
out.social = {
|
||||||
|
ok: true,
|
||||||
|
hint: 'Social se refresca al abrir la pestaña (Graph live + cache). Recarga la página.',
|
||||||
|
reload: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(out);
|
||||||
|
}
|
||||||
@@ -12,18 +12,30 @@ import {
|
|||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
serviceSupabase,
|
serviceSupabase,
|
||||||
} from '@/lib/assets';
|
} from '@/lib/assets';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID, createHash } from 'crypto';
|
||||||
|
import {
|
||||||
|
newId,
|
||||||
|
parseBrandbook,
|
||||||
|
type BrandAsset,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
const MAX_LOGO = 15 * 1024 * 1024; // 15 MB
|
||||||
const ALLOWED = new Set([
|
const ALLOWED_MIMES = new Set([
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'image/svg+xml',
|
'image/svg+xml',
|
||||||
|
'image/svg',
|
||||||
|
'image/x-png',
|
||||||
|
'image/pjpeg',
|
||||||
]);
|
]);
|
||||||
|
const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']);
|
||||||
|
|
||||||
async function requireAgency() {
|
async function requireAgency() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -64,18 +76,36 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
if (file.size > MAX_LOGO) {
|
if (file.size > MAX_LOGO) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Logo máximo 2 MB' },
|
{ error: 'Logo máximo 15 MB' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mime = file.type || 'application/octet-stream';
|
|
||||||
if (!ALLOWED.has(mime)) {
|
const rawMime = (file.type || '').split(';')[0].trim().toLowerCase();
|
||||||
|
const ext = (file.name || '').split('.').pop()?.toLowerCase() || '';
|
||||||
|
const isAllowed =
|
||||||
|
ALLOWED_MIMES.has(rawMime) ||
|
||||||
|
rawMime.startsWith('image/') ||
|
||||||
|
ALLOWED_EXTS.has(ext);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
{ error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedMime =
|
||||||
|
rawMime && rawMime !== 'application/octet-stream'
|
||||||
|
? rawMime
|
||||||
|
: ext === 'svg'
|
||||||
|
? 'image/svg+xml'
|
||||||
|
: ext === 'webp'
|
||||||
|
? 'image/webp'
|
||||||
|
: ext === 'png'
|
||||||
|
? 'image/png'
|
||||||
|
: 'image/jpeg';
|
||||||
|
|
||||||
const bucketOk = await ensureAssetsBucket();
|
const bucketOk = await ensureAssetsBucket();
|
||||||
if (!bucketOk.ok) {
|
if (!bucketOk.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -90,15 +120,51 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const safe = sanitizeFileName(file.name || 'logo.png');
|
const safe = sanitizeFileName(file.name || 'logo.png');
|
||||||
const path = `${tenantId}/branding/${randomUUID()}_${safe}`;
|
const assetId = randomUUID();
|
||||||
|
const folder = 'branding';
|
||||||
|
const path = `${tenantId}/${folder}/${assetId}_${safe}`;
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
const sha256 = createHash('sha256').update(buf).digest('hex');
|
||||||
|
|
||||||
const { error: upErr } = await sb.storage
|
const { error: upErr } = await sb.storage
|
||||||
.from(ASSETS_BUCKET)
|
.from(ASSETS_BUCKET)
|
||||||
.upload(path, buf, { contentType: mime, upsert: true });
|
.upload(path, buf, { contentType: resolvedMime, upsert: true });
|
||||||
if (upErr) {
|
if (upErr) {
|
||||||
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register in asset_files table so it can be previewed/downloaded cleanly
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: assetId,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
title: safe,
|
||||||
|
folder,
|
||||||
|
file_name: safe,
|
||||||
|
mime_type: resolvedMime,
|
||||||
|
bytes: file.size,
|
||||||
|
storage_path: path,
|
||||||
|
sha256,
|
||||||
|
status: 'approved',
|
||||||
|
created_by: user?.id || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-blocking
|
||||||
|
}
|
||||||
|
|
||||||
// Store storage path; served via /api/branding/logo
|
// Store storage path; served via /api/branding/logo
|
||||||
const logoUrl = `storage:${path}`;
|
const logoUrl = `storage:${path}`;
|
||||||
const patch = await fetch(
|
const patch = await fetch(
|
||||||
@@ -125,6 +191,51 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also sync into documents (brandbook) kit_assets so it appears in Zona de marca
|
||||||
|
try {
|
||||||
|
const docRes = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(tenantId)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (docRes.ok) {
|
||||||
|
const docs = await docRes.json();
|
||||||
|
const doc = docs[0];
|
||||||
|
if (doc) {
|
||||||
|
const book = parseBrandbook(doc.content);
|
||||||
|
if (book) {
|
||||||
|
const asset: BrandAsset = {
|
||||||
|
id: newId('kit'),
|
||||||
|
asset_file_id: assetId,
|
||||||
|
kind: 'logo',
|
||||||
|
role: 'primary',
|
||||||
|
label: safe,
|
||||||
|
file_name: safe,
|
||||||
|
mime_type: resolvedMime,
|
||||||
|
added_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
book.kit_assets = [
|
||||||
|
asset,
|
||||||
|
...(book.kit_assets || []).filter((a) => a.role !== 'primary'),
|
||||||
|
];
|
||||||
|
await fetch(`${supabaseUrl()}/rest/v1/documents?id=eq.${doc.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: book,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-blocking
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
logoUrl,
|
logoUrl,
|
||||||
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
|
|||||||
46
src/app/api/ads/daily/route.ts
Normal file
46
src/app/api/ads/daily/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { fetchAdDailyPaged } from '@/lib/fetch-ad-daily';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
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({
|
||||||
|
rows: [],
|
||||||
|
warning: 'Selecciona un cliente en Contexto',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = req.nextUrl.searchParams.get('since') || '';
|
||||||
|
const until = req.nextUrl.searchParams.get('until') || '';
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(since) || !/^\d{4}-\d{2}-\d{2}$/.test(until)) {
|
||||||
|
return NextResponse.json({ error: 'since y until (YYYY-MM-DD)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
const rows = await fetchAdDailyPaged({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
useServiceRole: agency && ctx.viaView,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
rows,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
tenant_label: ctx.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
359
src/app/api/assets/contacts/route.ts
Normal file
359
src/app/api/assets/contacts/route.ts
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export type ContactMethod = 'Email' | 'Grupo WhatsApp' | 'Teams';
|
||||||
|
|
||||||
|
export interface ClientContact {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string | null;
|
||||||
|
contact_methods: ContactMethod[];
|
||||||
|
notes?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE = 'client_contacts';
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = supabaseUrl();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gate(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const apiKey = req.headers.get('x-api-key');
|
||||||
|
const validApiKey = process.env.CRON_SECRET || '4v-cron-sec-2026-mkt';
|
||||||
|
const isApiAuth = Boolean(apiKey && apiKey === validApiKey);
|
||||||
|
|
||||||
|
if (!user && !isApiAuth) {
|
||||||
|
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryTenant =
|
||||||
|
req.nextUrl.searchParams.get('tenant_id') ||
|
||||||
|
req.nextUrl.searchParams.get('tenantId');
|
||||||
|
const role = user ? userRole(user) : 'admin';
|
||||||
|
const isAgency = user ? isAgencyRole(role) : true;
|
||||||
|
|
||||||
|
let tenantId: string | null = null;
|
||||||
|
let label = 'Cliente';
|
||||||
|
|
||||||
|
if ((isAgency || isApiAuth) && queryTenant) {
|
||||||
|
tenantId = queryTenant;
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: t } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('name')
|
||||||
|
.eq('id', queryTenant)
|
||||||
|
.maybeSingle();
|
||||||
|
if (t?.name) label = t.name;
|
||||||
|
} else if (user) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (ctx.portfolioWide || !ctx.tenantId) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto antes de gestionar el directorio.' },
|
||||||
|
{ status: 400 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
label = ctx.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'No se pudo identificar el tenant objetivo.' },
|
||||||
|
{ status: 400 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, tenantId, label, isAgency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/assets/contacts
|
||||||
|
* Lista las personas del directorio del cliente activo.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g) return g.error;
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content, updated_at')
|
||||||
|
.eq('tenant_id', g.tenantId)
|
||||||
|
.eq('type', DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let contacts: ClientContact[] = [];
|
||||||
|
if (data?.content) {
|
||||||
|
try {
|
||||||
|
contacts = JSON.parse(data.content);
|
||||||
|
} catch {
|
||||||
|
contacts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ contacts, label: g.label });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/assets/contacts
|
||||||
|
* Añade una nueva persona al directorio del cliente.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g) return g.error;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body) {
|
||||||
|
return NextResponse.json({ error: 'Cuerpo de petición inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, role, email, phone, contact_methods, notes } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Nombre y Apellido es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!role || typeof role !== 'string' || !role.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Cargo es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!email || typeof email !== 'string' || !email.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Email es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validMethods: ContactMethod[] = ['Email', 'Grupo WhatsApp', 'Teams'];
|
||||||
|
const methods: ContactMethod[] = Array.isArray(contact_methods)
|
||||||
|
? contact_methods.filter((m: any): m is ContactMethod => validMethods.includes(m))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
|
||||||
|
// Obtener registro existente
|
||||||
|
const { data: existingDoc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', g.tenantId)
|
||||||
|
.eq('type', DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
let contacts: ClientContact[] = [];
|
||||||
|
if (existingDoc?.content) {
|
||||||
|
try {
|
||||||
|
contacts = JSON.parse(existingDoc.content);
|
||||||
|
} catch {
|
||||||
|
contacts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newContact: ClientContact = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: name.trim(),
|
||||||
|
role: role.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
phone: phone && typeof phone === 'string' && phone.trim() ? phone.trim() : null,
|
||||||
|
contact_methods: methods.length > 0 ? methods : ['Email'],
|
||||||
|
notes: notes && typeof notes === 'string' && notes.trim() ? notes.trim() : null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
created_by: g.user?.email || (g.isAgency ? 'Agencia' : 'Cliente'),
|
||||||
|
};
|
||||||
|
|
||||||
|
contacts.unshift(newContact);
|
||||||
|
|
||||||
|
if (existingDoc) {
|
||||||
|
const { error: updateErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: JSON.stringify(contacts),
|
||||||
|
title: 'Directorio de Personas del Cliente',
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.eq('id', existingDoc.id);
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
return NextResponse.json({ error: updateErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error: insertErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.insert({
|
||||||
|
tenant_id: g.tenantId,
|
||||||
|
type: DOC_TYPE,
|
||||||
|
title: 'Directorio de Personas del Cliente',
|
||||||
|
content: JSON.stringify(contacts),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertErr) {
|
||||||
|
return NextResponse.json({ error: insertErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, contact: newContact, contacts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/assets/contacts
|
||||||
|
* Actualiza una persona existente.
|
||||||
|
*/
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g) return g.error;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null);
|
||||||
|
if (!body || !body.id) {
|
||||||
|
return NextResponse.json({ error: 'ID de persona obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, name, role, email, phone, contact_methods, notes } = body;
|
||||||
|
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Nombre y Apellido es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!role || typeof role !== 'string' || !role.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Cargo es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!email || typeof email !== 'string' || !email.trim()) {
|
||||||
|
return NextResponse.json({ error: 'El Email es obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validMethods: ContactMethod[] = ['Email', 'Grupo WhatsApp', 'Teams'];
|
||||||
|
const methods: ContactMethod[] = Array.isArray(contact_methods)
|
||||||
|
? contact_methods.filter((m: any): m is ContactMethod => validMethods.includes(m))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: existingDoc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', g.tenantId)
|
||||||
|
.eq('type', DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!existingDoc?.content) {
|
||||||
|
return NextResponse.json({ error: 'No se encontró el directorio de contactos' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let contacts: ClientContact[] = [];
|
||||||
|
try {
|
||||||
|
contacts = JSON.parse(existingDoc.content);
|
||||||
|
} catch {
|
||||||
|
contacts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = contacts.findIndex((c) => c.id === id);
|
||||||
|
if (idx === -1) {
|
||||||
|
return NextResponse.json({ error: 'Contacto no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
contacts[idx] = {
|
||||||
|
...contacts[idx],
|
||||||
|
name: name.trim(),
|
||||||
|
role: role.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
phone: phone && typeof phone === 'string' && phone.trim() ? phone.trim() : null,
|
||||||
|
contact_methods: methods.length > 0 ? methods : ['Email'],
|
||||||
|
notes: notes && typeof notes === 'string' && notes.trim() ? notes.trim() : null,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: updateErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: JSON.stringify(contacts),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.eq('id', existingDoc.id);
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
return NextResponse.json({ error: updateErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, contact: contacts[idx], contacts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/assets/contacts?id=...
|
||||||
|
* Elimina una persona del directorio.
|
||||||
|
*/
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g) return g.error;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'ID de persona obligatorio.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: existingDoc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', g.tenantId)
|
||||||
|
.eq('type', DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!existingDoc?.content) {
|
||||||
|
return NextResponse.json({ error: 'No se encontraron contactos.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let contacts: ClientContact[] = [];
|
||||||
|
try {
|
||||||
|
contacts = JSON.parse(existingDoc.content);
|
||||||
|
} catch {
|
||||||
|
contacts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextContacts = contacts.filter((c) => c.id !== id);
|
||||||
|
|
||||||
|
const { error: updateErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: JSON.stringify(nextContacts),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', existingDoc.id);
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
return NextResponse.json({ error: updateErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, contacts: nextContacts });
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
filterDriveEntriesForClientRoot,
|
||||||
|
isAppGeneratedDriveFolderName,
|
||||||
|
} from '@/lib/assets-client-scope';
|
||||||
import {
|
import {
|
||||||
findClientesRootFolder,
|
findClientesRootFolder,
|
||||||
getDriveDownloadUrl,
|
getDriveDownloadUrl,
|
||||||
@@ -15,7 +19,33 @@ export const dynamic = 'force-dynamic';
|
|||||||
|
|
||||||
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
||||||
|
|
||||||
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */
|
async function listAppGeneratedRootFolderIds(
|
||||||
|
clientRootId: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rootFiles = await listFilesInFolder(clientRootId);
|
||||||
|
return rootFiles
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
f.mimeType === FOLDER_MIME && isAppGeneratedDriveFolderName(f.name)
|
||||||
|
)
|
||||||
|
.map((f) => f.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isUnderAppGeneratedTree(
|
||||||
|
fileOrFolderId: string,
|
||||||
|
clientRootId: string,
|
||||||
|
appRootIds?: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
const roots =
|
||||||
|
appRootIds ?? (await listAppGeneratedRootFolderIds(clientRootId));
|
||||||
|
for (const appId of roots) {
|
||||||
|
if (fileOrFolderId === appId) return true;
|
||||||
|
if (await isUnderDriveFolder(fileOrFolderId, appId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — Drive tree for active tenant. Agency: full tree. Client: app folders only. */
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -33,7 +63,7 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void isAgencyRole(userRole(user));
|
const agency = isAgencyRole(userRole(user));
|
||||||
|
|
||||||
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
|
||||||
if (downloadId) {
|
if (downloadId) {
|
||||||
@@ -52,6 +82,15 @@ export async function GET(req: NextRequest) {
|
|||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!agency) {
|
||||||
|
const inApp = await isUnderAppGeneratedTree(downloadId, link.folderId);
|
||||||
|
if (!inApp) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Archivo fuera del alcance del cliente' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const url = await getDriveDownloadUrl(downloadId);
|
const url = await getDriveDownloadUrl(downloadId);
|
||||||
return NextResponse.json({ url });
|
return NextResponse.json({ url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -67,6 +106,8 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
files: [],
|
files: [],
|
||||||
linked: false,
|
linked: false,
|
||||||
|
agency,
|
||||||
|
clientScope: agency ? 'full' : 'app_generated',
|
||||||
warning:
|
warning:
|
||||||
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
|
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
|
||||||
});
|
});
|
||||||
@@ -88,6 +129,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
|
||||||
let folderId = link.folderId;
|
let folderId = link.folderId;
|
||||||
|
let appRootIds: string[] | undefined;
|
||||||
|
|
||||||
if (requested && requested !== link.folderId) {
|
if (requested && requested !== link.folderId) {
|
||||||
const allowed = await isUnderDriveFolder(requested, link.folderId);
|
const allowed = await isUnderDriveFolder(requested, link.folderId);
|
||||||
@@ -102,13 +144,32 @@ export async function GET(req: NextRequest) {
|
|||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!agency) {
|
||||||
|
appRootIds = await listAppGeneratedRootFolderIds(link.folderId);
|
||||||
|
const inApp = await isUnderAppGeneratedTree(
|
||||||
|
requested,
|
||||||
|
link.folderId,
|
||||||
|
appRootIds
|
||||||
|
);
|
||||||
|
if (!inApp) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Solo puedes abrir carpetas generadas por el Centro de Mando (creatividades, calendarios…).',
|
||||||
|
rootFolderId: link.folderId,
|
||||||
|
folderName: link.folderName,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
folderId = requested;
|
folderId = requested;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentMeta = await getDriveFileMeta(folderId);
|
const currentMeta = await getDriveFileMeta(folderId);
|
||||||
const files = await listFilesInFolder(folderId);
|
const files = await listFilesInFolder(folderId);
|
||||||
const mapped = files
|
let mapped = files
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
@@ -124,19 +185,34 @@ export async function GET(req: NextRequest) {
|
|||||||
return a.name.localeCompare(b.name, 'es');
|
return a.name.localeCompare(b.name, 'es');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const atClientRoot = folderId === link.folderId;
|
||||||
|
if (!agency && atClientRoot) {
|
||||||
|
mapped = filterDriveEntriesForClientRoot(mapped);
|
||||||
|
if (!mapped.length && !scopeWarning) {
|
||||||
|
scopeWarning =
|
||||||
|
'Aún no hay carpetas generadas por la app (Creatividades-Ads / Creatividades-RRSS). El resto del Drive solo lo ve la agencia.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
linked: true,
|
linked: true,
|
||||||
|
agency,
|
||||||
|
clientScope: agency ? 'full' : 'app_generated',
|
||||||
rootFolderId: link.folderId,
|
rootFolderId: link.folderId,
|
||||||
rootFolderName: link.folderName,
|
rootFolderName: link.folderName,
|
||||||
folderId,
|
folderId,
|
||||||
folderName: currentMeta?.name || link.folderName,
|
folderName: currentMeta?.name || link.folderName,
|
||||||
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
|
driveOpenUrl: agency
|
||||||
|
? `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`
|
||||||
|
: null,
|
||||||
files: mapped,
|
files: mapped,
|
||||||
warning: scopeWarning,
|
warning: scopeWarning,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
linked: true,
|
linked: true,
|
||||||
|
agency,
|
||||||
|
clientScope: agency ? 'full' : 'app_generated',
|
||||||
rootFolderId: link.folderId,
|
rootFolderId: link.folderId,
|
||||||
rootFolderName: link.folderName,
|
rootFolderName: link.folderName,
|
||||||
folderId,
|
folderId,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
sanitizeFolder,
|
sanitizeFolder,
|
||||||
serviceSupabase,
|
serviceSupabase,
|
||||||
} from '@/lib/assets';
|
} from '@/lib/assets';
|
||||||
|
import {
|
||||||
|
filterPortalAssetsForClient,
|
||||||
|
isAppGeneratedPortalFolder,
|
||||||
|
} from '@/lib/assets-client-scope';
|
||||||
import {
|
import {
|
||||||
ensureChildFolder,
|
ensureChildFolder,
|
||||||
getTenantDriveFolderId,
|
getTenantDriveFolderId,
|
||||||
@@ -64,12 +68,18 @@ export async function GET() {
|
|||||||
const msg = await r.text();
|
const msg = await r.text();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
assets: [],
|
assets: [],
|
||||||
|
agency: true,
|
||||||
|
clientScope: 'full',
|
||||||
warning: msg.includes('does not exist')
|
warning: msg.includes('does not exist')
|
||||||
? 'Aplicar sql/asset_files.sql'
|
? 'Aplicar sql/asset_files.sql'
|
||||||
: msg.slice(0, 200),
|
: msg.slice(0, 200),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return NextResponse.json({ assets: await r.json() });
|
return NextResponse.json({
|
||||||
|
assets: await r.json(),
|
||||||
|
agency: true,
|
||||||
|
clientScope: 'full',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -81,19 +91,27 @@ export async function GET() {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
assets: [],
|
assets: [],
|
||||||
|
agency: false,
|
||||||
|
clientScope: 'app_generated',
|
||||||
warning: error.message,
|
warning: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return NextResponse.json({ assets: data ?? [] });
|
// Client JWT path: still filter to app-generated folders only
|
||||||
|
return NextResponse.json({
|
||||||
|
assets: filterPortalAssetsForClient(data ?? []),
|
||||||
|
agency: false,
|
||||||
|
clientScope: 'app_generated',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST multipart: files[] + optional folder */
|
/** POST multipart: files[] + optional folder */
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const g = await gate();
|
const g = await gate();
|
||||||
if ('error' in g && g.error) return g.error;
|
if ('error' in g && g.error) return g.error;
|
||||||
const { user, ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
const { user, ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
||||||
user: { id: string };
|
user: { id: string };
|
||||||
ctx: { tenantId: string };
|
ctx: { tenantId: string };
|
||||||
|
agency: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const bucketOk = await ensureAssetsBucket();
|
const bucketOk = await ensureAssetsBucket();
|
||||||
@@ -115,6 +133,15 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
const folder = sanitizeFolder(String(form.get('folder') || ''));
|
const folder = sanitizeFolder(String(form.get('folder') || ''));
|
||||||
|
if (!agency && !isAppGeneratedPortalFolder(folder)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Como cliente solo puedes usar carpetas generadas por la app (creatividades, calendarios, brandbook…).',
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
||||||
|
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
@@ -234,10 +261,18 @@ export async function POST(req: NextRequest) {
|
|||||||
export async function DELETE(req: NextRequest) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const g = await gate();
|
const g = await gate();
|
||||||
if ('error' in g && g.error) return g.error;
|
if ('error' in g && g.error) return g.error;
|
||||||
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
|
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
|
||||||
ctx: { tenantId: string };
|
ctx: { tenantId: string };
|
||||||
|
agency: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!agency) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Solo la agencia puede eliminar archivos de la biblioteca' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const id = req.nextUrl.searchParams.get('id')?.trim();
|
const id = req.nextUrl.searchParams.get('id')?.trim();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||||
|
|||||||
287
src/app/api/assets/urls/route.ts
Normal file
287
src/app/api/assets/urls/route.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export interface ClientUrlAsset {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
created_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = supabaseUrl();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gate(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
// Permite autenticación por x-api-key en llamadas directas
|
||||||
|
const apiKey = req.headers.get('x-api-key');
|
||||||
|
const validApiKey = process.env.CRON_SECRET || '4v-cron-sec-2026-mkt';
|
||||||
|
const isApiAuth = Boolean(apiKey && apiKey === validApiKey);
|
||||||
|
|
||||||
|
if (!user && !isApiAuth) {
|
||||||
|
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryTenant =
|
||||||
|
req.nextUrl.searchParams.get('tenant_id') ||
|
||||||
|
req.nextUrl.searchParams.get('tenantId');
|
||||||
|
const role = user ? userRole(user) : 'admin';
|
||||||
|
const isAgency = user ? isAgencyRole(role) : true;
|
||||||
|
|
||||||
|
let tenantId: string | null = null;
|
||||||
|
let label = 'Cliente';
|
||||||
|
|
||||||
|
if ((isAgency || isApiAuth) && queryTenant) {
|
||||||
|
tenantId = queryTenant;
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: t } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('name')
|
||||||
|
.eq('id', queryTenant)
|
||||||
|
.maybeSingle();
|
||||||
|
if (t?.name) label = t.name;
|
||||||
|
} else if (user) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (ctx.portfolioWide || !ctx.tenantId) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto antes de gestionar URLs.' },
|
||||||
|
{ status: 400 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
label = ctx.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'No se pudo identificar el tenant objetivo.' },
|
||||||
|
{ status: 400 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, tenantId, label, isAgency };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUrlsForTenant(tenantId: string): Promise<{ docId: string | null; urls: ClientUrlAsset[] }> {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: row } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'client_urls')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!row) return { docId: null, urls: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof row.content === 'string' ? JSON.parse(row.content) : row.content;
|
||||||
|
const list = Array.isArray(parsed) ? parsed : parsed?.urls || [];
|
||||||
|
return { docId: row.id, urls: list };
|
||||||
|
} catch {
|
||||||
|
return { docId: row.id, urls: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUrlsForTenant(tenantId: string, docId: string | null, urls: ClientUrlAsset[]) {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const payload = {
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: 'client_urls',
|
||||||
|
title: 'Enlaces y URLs del Cliente',
|
||||||
|
content: JSON.stringify(urls),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (docId) {
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: payload.content,
|
||||||
|
updated_at: payload.updated_at,
|
||||||
|
})
|
||||||
|
.eq('id', docId);
|
||||||
|
} else {
|
||||||
|
await admin
|
||||||
|
.from('documents')
|
||||||
|
.insert(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(raw: string): string {
|
||||||
|
let s = (raw || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (!/^https?:\/\//i.test(s)) {
|
||||||
|
s = 'https://' + s;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId, label } = g as { tenantId: string; label: string };
|
||||||
|
|
||||||
|
const { urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
return NextResponse.json({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
tenant_label: label,
|
||||||
|
urls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId, user } = g as { tenantId: string; user?: any };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const rawUrl = body.url?.trim();
|
||||||
|
const description = body.description?.trim();
|
||||||
|
const title = body.title?.trim() || '';
|
||||||
|
const category = body.category?.trim() || 'General';
|
||||||
|
|
||||||
|
if (!rawUrl) {
|
||||||
|
return NextResponse.json({ error: 'La URL es obligatoria' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
return NextResponse.json({ error: 'El texto descriptivo es obligatorio' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUrl = normalizeUrl(rawUrl);
|
||||||
|
|
||||||
|
// Intentar derivar un título limpio si no se proporcionó
|
||||||
|
let finalTitle = title;
|
||||||
|
if (!finalTitle) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cleanUrl);
|
||||||
|
finalTitle = parsed.hostname.replace(/^www\./, '');
|
||||||
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
const segment = parsed.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (segment) finalTitle += ` / ${segment.replace(/[-_]/g, ' ')}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finalTitle = 'Enlace web';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAsset: ClientUrlAsset = {
|
||||||
|
id: `url_${Date.now()}_${randomUUID().slice(0, 8)}`,
|
||||||
|
url: cleanUrl,
|
||||||
|
title: finalTitle,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
created_by: user?.email || 'sistema',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const updatedUrls = [newAsset, ...urls];
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: newAsset,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error al guardar la URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId } = g as { tenantId: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'ID de URL no proporcionado' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const index = urls.findIndex((u) => u.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
return NextResponse.json({ error: 'URL no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = urls[index];
|
||||||
|
const cleanUrl = body.url ? normalizeUrl(body.url) : current.url;
|
||||||
|
|
||||||
|
const updatedItem: ClientUrlAsset = {
|
||||||
|
...current,
|
||||||
|
url: cleanUrl,
|
||||||
|
title: body.title !== undefined ? body.title.trim() : current.title,
|
||||||
|
description: body.description !== undefined ? body.description.trim() : current.description,
|
||||||
|
category: body.category !== undefined ? body.category.trim() : current.category,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedUrls = [...urls];
|
||||||
|
updatedUrls[index] = updatedItem;
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url: updatedItem,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error al actualizar la URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const g = await gate(req);
|
||||||
|
if ('error' in g && g.error) return g.error;
|
||||||
|
const { tenantId } = g as { tenantId: string };
|
||||||
|
|
||||||
|
const id = req.nextUrl.searchParams.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'ID requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { docId, urls } = await loadUrlsForTenant(tenantId);
|
||||||
|
const updatedUrls = urls.filter((u) => u.id !== id);
|
||||||
|
|
||||||
|
await saveUrlsForTenant(tenantId, docId, updatedUrls);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
urls: updatedUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
122
src/app/api/brandbook/analyze-b2b/route.ts
Normal file
122
src/app/api/brandbook/analyze-b2b/route.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { fetchStrategyWebpage, analyzeB2BWithClaude } from '@/lib/b2b-analyzer';
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const apiKeyHeader =
|
||||||
|
req.headers.get('x-api-key') ||
|
||||||
|
req.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
||||||
|
const expectedApiKey =
|
||||||
|
process.env.INTEGRATIONS_API_KEY ||
|
||||||
|
process.env.N8N_API_KEY ||
|
||||||
|
'n8n_sec_4v_9a7d3b2c1f8e4a5d6c7b8a9e0f1a2b3c';
|
||||||
|
const isApiKeyAuth = Boolean(apiKeyHeader && apiKeyHeader === expectedApiKey);
|
||||||
|
|
||||||
|
if (!user && !isApiKeyAuth) {
|
||||||
|
return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = typeof body.url === 'string' ? body.url.trim() : '';
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Debes proporcionar la URL de la estrategia o documento B2B' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver tenant
|
||||||
|
let tenantId: string | null = body.tenant_id || null;
|
||||||
|
if (!tenantId && user) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
let brandName = 'Marca';
|
||||||
|
let extraContext = '';
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
const { data: tenant } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('name, creative_style')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant?.name) brandName = tenant.name;
|
||||||
|
|
||||||
|
const { data: doc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'brandbook')
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (doc?.content) {
|
||||||
|
const book =
|
||||||
|
typeof doc.content === 'string'
|
||||||
|
? JSON.parse(doc.content)
|
||||||
|
: doc.content;
|
||||||
|
extraContext = `Tagline: ${book.tagline || ''} | Elevator pitch: ${
|
||||||
|
book.elevator_pitch || ''
|
||||||
|
} | Vertical: ${book.vertical || ''} | Posicionamiento: ${
|
||||||
|
book.positioning || ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { text, filename } = await fetchStrategyWebpage(url);
|
||||||
|
if (!text || text.length < 50) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se pudo extraer contenido suficiente de la URL especificada.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await analyzeB2BWithClaude(text, brandName, extraContext);
|
||||||
|
result.source_filename = filename;
|
||||||
|
result.source_excerpt = text.slice(0, 1000);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
brand_name: brandName,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Error al analizar la estrategia con IA',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/api/brandbook/analyze-blog/route.ts
Normal file
124
src/app/api/brandbook/analyze-blog/route.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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 { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import { fetchBlogPosts, analyzeBlogWithClaude } from '@/lib/blog-analyzer';
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const apiKeyHeader =
|
||||||
|
req.headers.get('x-api-key') ||
|
||||||
|
req.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
||||||
|
const expectedApiKey =
|
||||||
|
process.env.INTEGRATIONS_API_KEY ||
|
||||||
|
process.env.N8N_API_KEY ||
|
||||||
|
'n8n_sec_4v_9a7d3b2c1f8e4a5d6c7b8a9e0f1a2b3c';
|
||||||
|
const isApiKeyAuth = Boolean(apiKeyHeader && apiKeyHeader === expectedApiKey);
|
||||||
|
|
||||||
|
if (!user && !isApiKeyAuth) {
|
||||||
|
return NextResponse.json({ error: 'No autorizado' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = typeof body.url === 'string' ? body.url.trim() : '';
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Debes proporcionar la URL del blog corporativo' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver tenant
|
||||||
|
let tenantId: string | null = body.tenant_id || null;
|
||||||
|
if (!tenantId && user) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
let brandName = 'Marca';
|
||||||
|
let extraContext = '';
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
const { data: tenant } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('name, slug')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant?.name) {
|
||||||
|
brandName = tenant.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar si hay brandbook previo para dar contexto a Claude
|
||||||
|
const { data: doc } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', 'brandbook')
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (doc?.content) {
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof doc.content === 'string'
|
||||||
|
? JSON.parse(doc.content)
|
||||||
|
: doc.content;
|
||||||
|
const b = parsed.content || parsed;
|
||||||
|
if (b.brand_name) brandName = b.brand_name;
|
||||||
|
if (b.elevator_pitch || b.value_proposition) {
|
||||||
|
extraContext = `Propuesta de valor: ${b.value_proposition || b.elevator_pitch || ''}\nVertical: ${b.vertical || ''}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Extraer artículos de la URL del blog
|
||||||
|
const posts = await fetchBlogPosts(url);
|
||||||
|
|
||||||
|
if (!posts || posts.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No se encontraron artículos en la URL proporcionada. Revisa que sea accesible y contenga posts.',
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Analizar con Claude
|
||||||
|
const analysis = await analyzeBlogWithClaude(posts, brandName, extraContext);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
brand_name: brandName,
|
||||||
|
posts_count: posts.length,
|
||||||
|
result: analysis,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[analyze-blog] Error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Error analizando el blog con IA' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/app/api/brandbook/enrich/route.ts
Normal file
157
src/app/api/brandbook/enrich/route.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* POST — auto-fill Zona de marca for one or all tenants from available sources.
|
||||||
|
* Body: { tenant_id?, slug?, all?: true, force_seed?: true, dry_run?: true }
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
enrichAllTenantBrandbooks,
|
||||||
|
enrichTenantBrandbook,
|
||||||
|
} from '@/lib/brandbook-enrich';
|
||||||
|
import { ensureCreativesDriveFolders } from '@/lib/creatives-drive';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
import { logChange } from '@/lib/change-history';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
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: 'Solo agencia' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
tenant_id?: string;
|
||||||
|
slug?: string;
|
||||||
|
all?: boolean;
|
||||||
|
force_seed?: boolean;
|
||||||
|
dry_run?: boolean;
|
||||||
|
ensure_drive?: boolean;
|
||||||
|
} = {};
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const forceSeed = Boolean(body.force_seed);
|
||||||
|
const dryRun = Boolean(body.dry_run);
|
||||||
|
const ensureDrive = body.ensure_drive !== false;
|
||||||
|
|
||||||
|
if (body.all) {
|
||||||
|
const results = await enrichAllTenantBrandbooks({
|
||||||
|
forceSeed,
|
||||||
|
dryRun,
|
||||||
|
slugFilter: body.slug,
|
||||||
|
});
|
||||||
|
if (ensureDrive && !dryRun) {
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) continue;
|
||||||
|
await ensureCreativesDriveFolders(r.tenantId, r.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
|
await logChange({
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'enrichment',
|
||||||
|
action: 'enrich_all',
|
||||||
|
summary: `Enriquecimiento masivo de Zona de marca · ${results.length} marca(s)`,
|
||||||
|
detail: {
|
||||||
|
count: results.length,
|
||||||
|
force_seed: forceSeed,
|
||||||
|
slug_filter: body.slug || null,
|
||||||
|
filled: results.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
fields: r.filledFields?.length || 0,
|
||||||
|
sources: r.sources,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
source: 'api/brandbook/enrich',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
count: results.length,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tenantId = body.tenant_id?.trim() || '';
|
||||||
|
if (!tenantId && body.slug) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (headers && base) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(
|
||||||
|
body.slug
|
||||||
|
)}&select=id&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const rows = (await r.json()) as { id: string }[];
|
||||||
|
tenantId = rows[0]?.id || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tenantId) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
tenantId = ctx.tenantId || '';
|
||||||
|
}
|
||||||
|
if (!tenantId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Indica tenant_id, slug, all:true o selecciona cliente' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await enrichTenantBrandbook({
|
||||||
|
tenantId,
|
||||||
|
forceSeed,
|
||||||
|
dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
let drive: { ok: boolean; ads?: string; rrss?: string; warning?: string } | null =
|
||||||
|
null;
|
||||||
|
if (ensureDrive && !dryRun && result.ok) {
|
||||||
|
drive = await ensureCreativesDriveFolders(result.tenantId, result.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun && result.ok) {
|
||||||
|
await logChange({
|
||||||
|
tenantId: result.tenantId,
|
||||||
|
tenantName: result.slug,
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'enrichment',
|
||||||
|
entityId: result.tenantId,
|
||||||
|
action: 'enrich',
|
||||||
|
summary: `Enriquecimiento Zona de marca · ${result.slug} · ${
|
||||||
|
result.filledFields?.length || 0
|
||||||
|
} campo(s)`,
|
||||||
|
detail: {
|
||||||
|
filled_fields: result.filledFields || [],
|
||||||
|
sources: result.sources || [],
|
||||||
|
completeness_before: result.completenessBefore,
|
||||||
|
completeness_after: result.completenessAfter,
|
||||||
|
force_seed: forceSeed,
|
||||||
|
},
|
||||||
|
source: 'api/brandbook/enrich',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: result.ok, result, drive });
|
||||||
|
}
|
||||||
44
src/app/api/brandbook/google-fonts/route.ts
Normal file
44
src/app/api/brandbook/google-fonts/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* GET ?q=&category=&limit=&offset= — full free Google Fonts catalog.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { listGoogleFonts } from '@/lib/google-fonts-catalog';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
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 sp = req.nextUrl.searchParams;
|
||||||
|
try {
|
||||||
|
const result = await listGoogleFonts({
|
||||||
|
q: sp.get('q') || undefined,
|
||||||
|
category: sp.get('category') || undefined,
|
||||||
|
limit: Number(sp.get('limit') || 80) || 80,
|
||||||
|
offset: Number(sp.get('offset') || 0) || 0,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
source: 'google-fonts',
|
||||||
|
license: 'All Google Fonts are free and open source (OFL / Apache / Ubuntu…)',
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'No se pudo cargar el catálogo de Google Fonts',
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/app/api/brandbook/kit/route.ts
Normal file
383
src/app/api/brandbook/kit/route.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* POST multipart — upload logo/font/image/doc into brand kit + link in brandbook.
|
||||||
|
* Fields: file, kind, role?, label?
|
||||||
|
* DELETE ?assetId= — unlink + optional delete file
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
ASSETS_BUCKET,
|
||||||
|
MAX_ASSET_BYTES,
|
||||||
|
ensureAssetsBucket,
|
||||||
|
sanitizeFileName,
|
||||||
|
serviceSupabase,
|
||||||
|
} from '@/lib/assets';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
BRANDBOOK_FOLDER,
|
||||||
|
brandbookCompleteness,
|
||||||
|
emptyBrandbook,
|
||||||
|
newId,
|
||||||
|
parseBrandbook,
|
||||||
|
type BrandAsset,
|
||||||
|
type BrandAssetKind,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import { logChange } from '@/lib/change-history';
|
||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const KINDS = new Set<BrandAssetKind>([
|
||||||
|
'logo',
|
||||||
|
'font',
|
||||||
|
'image',
|
||||||
|
'icon',
|
||||||
|
'document',
|
||||||
|
'pattern',
|
||||||
|
'video',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadBook(tenantId: string, label: string) {
|
||||||
|
const r = await restGet<{ id: string; content: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const row = r.data?.[0];
|
||||||
|
return {
|
||||||
|
id: row?.id || null,
|
||||||
|
book: parseBrandbook(row?.content) || emptyBrandbook(label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBook(
|
||||||
|
tenantId: string,
|
||||||
|
docId: string | null,
|
||||||
|
book: Brandbook
|
||||||
|
) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) throw new Error('No service role');
|
||||||
|
book.updated_at = new Date().toISOString();
|
||||||
|
if (docId) {
|
||||||
|
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${docId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: book,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: book.updated_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!patch.ok) throw new Error(await patch.text());
|
||||||
|
return docId;
|
||||||
|
}
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: book,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) throw new Error(await ins.text());
|
||||||
|
const rows = (await ins.json()) as { id: string }[];
|
||||||
|
return rows[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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 bucketOk = await ensureAssetsBucket();
|
||||||
|
if (!bucketOk.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Storage: ${bucketOk.error}` },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const svc = serviceSupabase();
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!svc || !headers) {
|
||||||
|
return NextResponse.json({ error: 'No service role' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
if (!(file instanceof File) || file.size <= 0) {
|
||||||
|
return NextResponse.json({ error: 'Archivo requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_ASSET_BYTES) {
|
||||||
|
return NextResponse.json({ error: 'Archivo > 80 MB' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindRaw = String(form.get('kind') || 'other') as BrandAssetKind;
|
||||||
|
const kind = KINDS.has(kindRaw) ? kindRaw : 'other';
|
||||||
|
const role = String(form.get('role') || kind).slice(0, 80);
|
||||||
|
const label = String(form.get('label') || file.name).slice(0, 120);
|
||||||
|
|
||||||
|
const safeName = sanitizeFileName(file.name);
|
||||||
|
const id = randomUUID();
|
||||||
|
const folder = `${BRANDBOOK_FOLDER}/${kind}`;
|
||||||
|
const storagePath = `${ctx.tenantId}/${folder}/${id}_${safeName}`;
|
||||||
|
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) {
|
||||||
|
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
title: label || 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]);
|
||||||
|
return NextResponse.json({ error: await ins.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset: BrandAsset = {
|
||||||
|
id: newId('kit'),
|
||||||
|
asset_file_id: id,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
label,
|
||||||
|
file_name: safeName,
|
||||||
|
mime_type: file.type || undefined,
|
||||||
|
added_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label);
|
||||||
|
book.brand_name = book.brand_name || ctx.label;
|
||||||
|
book.kit_assets = [asset, ...(book.kit_assets || [])];
|
||||||
|
|
||||||
|
// Auto-add font token when uploading a font file
|
||||||
|
if (kind === 'font') {
|
||||||
|
const family = label.replace(/\.(ttf|otf|woff2?|zip)$/i, '').trim() || label;
|
||||||
|
book.fonts_kit = [
|
||||||
|
{
|
||||||
|
id: newId('font'),
|
||||||
|
family,
|
||||||
|
role: /display|headline|titulo/i.test(role) ? 'headline' : 'body',
|
||||||
|
weights: '400,700',
|
||||||
|
source: 'file',
|
||||||
|
url_or_notes: `Archivo: ${safeName}`,
|
||||||
|
asset_file_id: id,
|
||||||
|
},
|
||||||
|
...(book.fonts_kit || []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveBook(ctx.tenantId, docId, book);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'No se pudo guardar brandbook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'logo') {
|
||||||
|
try {
|
||||||
|
await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ logo_url: `storage:${storagePath}` }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Non-blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
tenantName: ctx.label,
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'brandbook',
|
||||||
|
entityId: docId,
|
||||||
|
action: 'kit_upload',
|
||||||
|
summary: `Kit · subida ${kind}${role ? ` (${role})` : ''} · ${label} · ${ctx.label}`,
|
||||||
|
detail: {
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
label,
|
||||||
|
file_name: safeName,
|
||||||
|
asset_id: asset.id,
|
||||||
|
asset_file_id: id,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
field: 'kit_assets',
|
||||||
|
from: null,
|
||||||
|
to: `${kind}: ${label}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
revertable: false,
|
||||||
|
},
|
||||||
|
source: 'api/brandbook/kit',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
asset,
|
||||||
|
brandbook: book,
|
||||||
|
completeness: brandbookCompleteness(book),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kitId = req.nextUrl.searchParams.get('kitId') || '';
|
||||||
|
const fileId = req.nextUrl.searchParams.get('assetFileId') || '';
|
||||||
|
if (!kitId && !fileId) {
|
||||||
|
return NextResponse.json({ error: 'kitId requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label);
|
||||||
|
const removed = book.kit_assets.find(
|
||||||
|
(a) => a.id === kitId || a.asset_file_id === fileId
|
||||||
|
);
|
||||||
|
book.kit_assets = book.kit_assets.filter(
|
||||||
|
(a) => a.id !== kitId && a.asset_file_id !== fileId
|
||||||
|
);
|
||||||
|
if (removed?.asset_file_id) {
|
||||||
|
book.fonts_kit = book.fonts_kit.map((f) =>
|
||||||
|
f.asset_file_id === removed.asset_file_id
|
||||||
|
? { ...f, asset_file_id: undefined }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveBook(ctx.tenantId, docId, book);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
tenantName: ctx.label,
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'brandbook',
|
||||||
|
entityId: docId,
|
||||||
|
action: 'kit_delete',
|
||||||
|
summary: `Kit · eliminado ${removed?.kind || 'asset'}${
|
||||||
|
removed?.label ? ` (${removed.label})` : ''
|
||||||
|
} · ${ctx.label}`,
|
||||||
|
detail: {
|
||||||
|
removed,
|
||||||
|
kit_id: kitId || null,
|
||||||
|
asset_file_id: fileId || null,
|
||||||
|
changes: removed
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
field: 'kit_assets',
|
||||||
|
from: `${removed.kind}: ${removed.label}`,
|
||||||
|
to: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
revertable: false,
|
||||||
|
},
|
||||||
|
source: 'api/brandbook/kit',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
brandbook: book,
|
||||||
|
completeness: brandbookCompleteness(book),
|
||||||
|
});
|
||||||
|
}
|
||||||
415
src/app/api/brandbook/route.ts
Normal file
415
src/app/api/brandbook/route.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
/**
|
||||||
|
* GET / PUT brandbook for active tenant (documents type=brandbook).
|
||||||
|
* Sincronización bidireccional permanente con tenants.creative_style.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||||
|
import { isAgencyRole, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
brandbookCompleteness,
|
||||||
|
emptyBrandbook,
|
||||||
|
parseBrandbook,
|
||||||
|
seedBrandbook,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import {
|
||||||
|
buildRevertPatch,
|
||||||
|
diffFields,
|
||||||
|
logChange,
|
||||||
|
} from '@/lib/change-history';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function getAdmin() {
|
||||||
|
const url = supabaseUrl();
|
||||||
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
return createAdminClient(url, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDoc(tenantId: string): Promise<{
|
||||||
|
id: string | null;
|
||||||
|
book: Brandbook | null;
|
||||||
|
}> {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: row } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.select('id, content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', BRANDBOOK_DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!row) return { id: null, book: null };
|
||||||
|
return { id: row.id, book: parseBrandbook(row.content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 role = userRole(user);
|
||||||
|
const isAgency = isAgencyRole(role);
|
||||||
|
const queryTenantId =
|
||||||
|
req.nextUrl.searchParams.get('tenant_id') ||
|
||||||
|
req.nextUrl.searchParams.get('tenantId');
|
||||||
|
|
||||||
|
let tenantId: string | null = null;
|
||||||
|
let label = 'Cliente';
|
||||||
|
let slug = 'cliente';
|
||||||
|
|
||||||
|
if (isAgency && queryTenantId) {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: tenant } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.eq('id', queryTenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant) {
|
||||||
|
tenantId = tenant.id;
|
||||||
|
label = tenant.name || 'Cliente';
|
||||||
|
slug = tenant.slug || 'cliente';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
label = ctx.label;
|
||||||
|
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, book } = await loadDoc(tenantId);
|
||||||
|
let current = book || emptyBrandbook(label);
|
||||||
|
|
||||||
|
// Sincronizar / fusionar con tenants.creative_style para que absolutamente NADA se pierda
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: tenantRow } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('creative_style')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenantRow?.creative_style && typeof tenantRow.creative_style === 'object') {
|
||||||
|
const cs = tenantRow.creative_style as any;
|
||||||
|
const idObj = cs.identity || {};
|
||||||
|
const audObj = cs.audiences || {};
|
||||||
|
const argObj = cs.arguments || {};
|
||||||
|
const proObj = cs.prohibitions || {};
|
||||||
|
const voiObj = cs.voice || {};
|
||||||
|
const visObj = cs.visual || {};
|
||||||
|
const liObj = cs.linkedin || {};
|
||||||
|
|
||||||
|
current = {
|
||||||
|
...current,
|
||||||
|
website: current.website || idObj.website || '',
|
||||||
|
b2b_offering: current.b2b_offering || idObj.b2b_offering || '',
|
||||||
|
b2c_offering: current.b2c_offering || idObj.b2c_offering || '',
|
||||||
|
usp_differentiators: current.usp_differentiators || idObj.usp_differentiators || '',
|
||||||
|
current_priorities: current.current_priorities || idObj.current_priorities || '',
|
||||||
|
price_and_offer_framing: current.price_and_offer_framing || idObj.price_and_offer_framing || '',
|
||||||
|
elevator_pitch: current.elevator_pitch || idObj.summary || '',
|
||||||
|
tone: current.tone || voiObj.tone || '',
|
||||||
|
copy_structure_rules: current.copy_structure_rules || voiObj.copy_structure_rules || '',
|
||||||
|
english_terms_policy: current.english_terms_policy || voiObj.english_terms_policy || '',
|
||||||
|
power_words: current.power_words?.length ? current.power_words : (voiObj.power_words || []),
|
||||||
|
allowed_ctas: current.allowed_ctas?.length ? current.allowed_ctas : (voiObj.allowed_ctas || []),
|
||||||
|
surface_rules: current.surface_rules || visObj.surface_rules || '',
|
||||||
|
image_text_rules: current.image_text_rules || visObj.image_text_rules || '',
|
||||||
|
typography_headline: current.typography_headline || visObj.typography_headline || '',
|
||||||
|
typography_body: current.typography_body || visObj.typography_body || '',
|
||||||
|
b2b_personas: current.b2b_personas?.length ? current.b2b_personas : (audObj.b2b_personas || []),
|
||||||
|
b2c_personas: current.b2c_personas?.length ? current.b2c_personas : (audObj.b2c_personas || []),
|
||||||
|
customer_frustrations: current.customer_frustrations || audObj.customer_frustrations || '',
|
||||||
|
desired_transformation: current.desired_transformation || audObj.desired_transformation || '',
|
||||||
|
objections: current.objections?.length ? current.objections : (argObj.objections || []),
|
||||||
|
winning_hooks: current.winning_hooks?.length ? current.winning_hooks : (argObj.winning_hooks || []),
|
||||||
|
preferred_angles: current.preferred_angles?.length ? current.preferred_angles : (argObj.preferred_angles || []),
|
||||||
|
emoji_policy: current.emoji_policy || proObj.emoji_policy || '',
|
||||||
|
testimonials_policy: current.testimonials_policy || proObj.testimonials_policy || '',
|
||||||
|
competitors_never_mention: current.competitors_never_mention?.length ? current.competitors_never_mention : (proObj.competitors_never_mention || []),
|
||||||
|
linkedin_themes: current.linkedin_themes?.length ? current.linkedin_themes : (liObj.linkedin_themes || []),
|
||||||
|
linkedin_formats: current.linkedin_formats?.length ? current.linkedin_formats : (liObj.linkedin_formats || []),
|
||||||
|
thought_leadership: current.thought_leadership?.length ? current.thought_leadership : (liObj.thought_leadership || []),
|
||||||
|
buyer_roles: current.buyer_roles?.length ? current.buyer_roles : (liObj.buyer_roles || []),
|
||||||
|
case_study_angles: current.case_study_angles?.length ? current.case_study_angles : (liObj.case_study_angles || []),
|
||||||
|
linkedin_cta: current.linkedin_cta?.length ? current.linkedin_cta : (liObj.linkedin_cta || []),
|
||||||
|
abm_notes: current.abm_notes || liObj.abm_notes || '',
|
||||||
|
linkedin_strategy_url: current.linkedin_strategy_url || liObj.strategy_url || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeness = brandbookCompleteness(current);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id,
|
||||||
|
brandbook: current,
|
||||||
|
slug,
|
||||||
|
label,
|
||||||
|
completeness,
|
||||||
|
exists: !!book,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(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: 'Solo agencia' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
brandbook?: unknown;
|
||||||
|
seed?: boolean;
|
||||||
|
tenant_id?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryTenantId =
|
||||||
|
req.nextUrl.searchParams.get('tenant_id') ||
|
||||||
|
req.nextUrl.searchParams.get('tenantId') ||
|
||||||
|
body.tenant_id ||
|
||||||
|
body.tenantId;
|
||||||
|
|
||||||
|
let tenantId: string | null = null;
|
||||||
|
let label = 'Cliente';
|
||||||
|
let slug = 'cliente';
|
||||||
|
|
||||||
|
if (queryTenantId) {
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { data: tenant } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('id, name, slug')
|
||||||
|
.eq('id', queryTenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenant) {
|
||||||
|
tenantId = tenant.id;
|
||||||
|
label = tenant.name || 'Cliente';
|
||||||
|
slug = tenant.slug || 'cliente';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tenantId = ctx.tenantId;
|
||||||
|
label = ctx.label;
|
||||||
|
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
let book: Brandbook | null = null;
|
||||||
|
if (body.seed) {
|
||||||
|
book = seedBrandbook(slug, label);
|
||||||
|
} else {
|
||||||
|
book = parseBrandbook(body.brandbook);
|
||||||
|
}
|
||||||
|
if (!book) {
|
||||||
|
return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
book.updated_at = new Date().toISOString();
|
||||||
|
book.brand_name = book.brand_name || label;
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const { id, book: previousBook } = await loadDoc(tenantId);
|
||||||
|
const created = !id;
|
||||||
|
|
||||||
|
let docId = id;
|
||||||
|
if (id) {
|
||||||
|
const { error: patchErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.update({
|
||||||
|
content: book,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: book.updated_at,
|
||||||
|
})
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (patchErr) {
|
||||||
|
return NextResponse.json({ error: patchErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { data: insRow, error: insErr } = await admin
|
||||||
|
.from('documents')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: book,
|
||||||
|
updated_at: book.updated_at,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insErr) {
|
||||||
|
return NextResponse.json({ error: insErr.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
docId = insRow.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = body.seed
|
||||||
|
? [{ field: '_seed', from: null, to: 'brandbook seed aplicado' }]
|
||||||
|
: diffFields(
|
||||||
|
previousBook as unknown as Record<string, unknown>,
|
||||||
|
book as unknown as Record<string, unknown>
|
||||||
|
);
|
||||||
|
|
||||||
|
const revertPatch =
|
||||||
|
!body.seed && !created
|
||||||
|
? buildRevertPatch(
|
||||||
|
previousBook as unknown as Record<string, unknown>,
|
||||||
|
book as unknown as Record<string, unknown>
|
||||||
|
)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
tenantId,
|
||||||
|
tenantName: label,
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'brandbook',
|
||||||
|
entityId: docId,
|
||||||
|
action: body.seed ? 'seed' : created ? 'create' : 'update',
|
||||||
|
summary: body.seed
|
||||||
|
? `Seed de Zona de marca · ${label}`
|
||||||
|
: created
|
||||||
|
? `Creación de Zona de marca · ${label}`
|
||||||
|
: changes.length
|
||||||
|
? `Actualización Zona de marca · ${changes.length} campo(s) · ${label}`
|
||||||
|
: `Guardado Zona de marca · ${label}`,
|
||||||
|
detail: {
|
||||||
|
changes: changes.slice(0, 40),
|
||||||
|
revert_patch: revertPatch,
|
||||||
|
revertable: Object.keys(revertPatch).length > 0,
|
||||||
|
seed: Boolean(body.seed),
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
source: 'api/brandbook',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sincronizar simultáneamente con tenants.creative_style para consistencia 100%
|
||||||
|
const { data: tenantRow } = await admin
|
||||||
|
.from('tenants')
|
||||||
|
.select('creative_style, settings')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (tenantRow) {
|
||||||
|
const existingStyle = (tenantRow.creative_style && typeof tenantRow.creative_style === 'object') ? tenantRow.creative_style : {};
|
||||||
|
const updatedStyle = {
|
||||||
|
...existingStyle,
|
||||||
|
identity: {
|
||||||
|
...(existingStyle.identity || {}),
|
||||||
|
summary: book.elevator_pitch || existingStyle.identity?.summary || '',
|
||||||
|
website: book.website || existingStyle.identity?.website || '',
|
||||||
|
b2b_offering: book.b2b_offering || existingStyle.identity?.b2b_offering || '',
|
||||||
|
b2c_offering: book.b2c_offering || existingStyle.identity?.b2c_offering || '',
|
||||||
|
usp_differentiators: book.usp_differentiators || existingStyle.identity?.usp_differentiators || '',
|
||||||
|
current_priorities: book.current_priorities || existingStyle.identity?.current_priorities || '',
|
||||||
|
price_and_offer_framing: book.price_and_offer_framing || existingStyle.identity?.price_and_offer_framing || '',
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
...(existingStyle.voice || {}),
|
||||||
|
tone: book.tone || existingStyle.voice?.tone || '',
|
||||||
|
copy_structure_rules: book.copy_structure_rules || existingStyle.voice?.copy_structure_rules || '',
|
||||||
|
english_terms_policy: book.english_terms_policy || existingStyle.voice?.english_terms_policy || '',
|
||||||
|
power_words: book.power_words?.length ? book.power_words : existingStyle.voice?.power_words || [],
|
||||||
|
allowed_ctas: book.allowed_ctas?.length ? book.allowed_ctas : existingStyle.voice?.allowed_ctas || [],
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
...(existingStyle.visual || {}),
|
||||||
|
surface_rules: book.surface_rules || existingStyle.visual?.surface_rules || '',
|
||||||
|
image_text_rules: book.image_text_rules || existingStyle.visual?.image_text_rules || '',
|
||||||
|
typography_headline: book.typography_headline || existingStyle.visual?.typography_headline || '',
|
||||||
|
typography_body: book.typography_body || existingStyle.visual?.typography_body || '',
|
||||||
|
},
|
||||||
|
audiences: {
|
||||||
|
...(existingStyle.audiences || {}),
|
||||||
|
b2b_personas: book.b2b_personas?.length ? book.b2b_personas : existingStyle.audiences?.b2b_personas || [],
|
||||||
|
b2c_personas: book.b2c_personas?.length ? book.b2c_personas : existingStyle.audiences?.b2c_personas || [],
|
||||||
|
customer_frustrations: book.customer_frustrations || existingStyle.audiences?.customer_frustrations || '',
|
||||||
|
desired_transformation: book.desired_transformation || existingStyle.audiences?.desired_transformation || '',
|
||||||
|
},
|
||||||
|
arguments: {
|
||||||
|
...(existingStyle.arguments || {}),
|
||||||
|
objections: book.objections?.length ? book.objections : existingStyle.arguments?.objections || [],
|
||||||
|
winning_hooks: book.winning_hooks?.length ? book.winning_hooks : existingStyle.arguments?.winning_hooks || [],
|
||||||
|
preferred_angles: book.preferred_angles?.length ? book.preferred_angles : existingStyle.arguments?.preferred_angles || [],
|
||||||
|
},
|
||||||
|
prohibitions: {
|
||||||
|
...(existingStyle.prohibitions || {}),
|
||||||
|
emoji_policy: book.emoji_policy || existingStyle.prohibitions?.emoji_policy || '',
|
||||||
|
testimonials_policy: book.testimonials_policy || existingStyle.prohibitions?.testimonials_policy || '',
|
||||||
|
competitors_never_mention: book.competitors_never_mention?.length ? book.competitors_never_mention : existingStyle.prohibitions?.competitors_never_mention || [],
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
...(existingStyle.linkedin || {}),
|
||||||
|
linkedin_themes: book.linkedin_themes?.length ? book.linkedin_themes : existingStyle.linkedin?.linkedin_themes || [],
|
||||||
|
linkedin_formats: book.linkedin_formats?.length ? book.linkedin_formats : existingStyle.linkedin?.linkedin_formats || [],
|
||||||
|
thought_leadership: book.thought_leadership?.length ? book.thought_leadership : existingStyle.linkedin?.thought_leadership || [],
|
||||||
|
buyer_roles: book.buyer_roles?.length ? book.buyer_roles : existingStyle.linkedin?.buyer_roles || [],
|
||||||
|
case_study_angles: book.case_study_angles?.length ? book.case_study_angles : existingStyle.linkedin?.case_study_angles || [],
|
||||||
|
linkedin_cta: book.linkedin_cta?.length ? book.linkedin_cta : existingStyle.linkedin?.linkedin_cta || [],
|
||||||
|
abm_notes: book.abm_notes || existingStyle.linkedin?.abm_notes || '',
|
||||||
|
strategy_url: book.linkedin_strategy_url || existingStyle.linkedin?.strategy_url || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await admin
|
||||||
|
.from('tenants')
|
||||||
|
.update({
|
||||||
|
creative_style: updatedStyle,
|
||||||
|
settings: {
|
||||||
|
...(tenantRow.settings || {}),
|
||||||
|
creative_style: updatedStyle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq('id', tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
id: docId,
|
||||||
|
brandbook: book,
|
||||||
|
completeness: brandbookCompleteness(book),
|
||||||
|
});
|
||||||
|
}
|
||||||
283
src/app/api/brandbook/upload/route.ts
Normal file
283
src/app/api/brandbook/upload/route.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* POST multipart: file (.txt/.md/.csv/.json/.pdf/.docx) or JSON { text, filename }
|
||||||
|
* Appends source excerpt into the tenant brandbook.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
BRANDBOOK_DOC_TITLE,
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
brandbookCompleteness,
|
||||||
|
emptyBrandbook,
|
||||||
|
ingestSourceText,
|
||||||
|
parseBrandbook,
|
||||||
|
type Brandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import {
|
||||||
|
detectSourceExt,
|
||||||
|
extractSourceText,
|
||||||
|
} from '@/lib/extract-document-text';
|
||||||
|
import { logChange } from '@/lib/change-history';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
async function loadBook(tenantId: string, label: string): Promise<{
|
||||||
|
id: string | null;
|
||||||
|
book: Brandbook;
|
||||||
|
}> {
|
||||||
|
const r = await restGet<{ id: string; content: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const row = r.data?.[0];
|
||||||
|
return {
|
||||||
|
id: row?.id || null,
|
||||||
|
book: parseBrandbook(row?.content) || emptyBrandbook(label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = 'pegado.txt';
|
||||||
|
let text = '';
|
||||||
|
let url = '';
|
||||||
|
const ctype = req.headers.get('content-type') || '';
|
||||||
|
if (ctype.includes('multipart/form-data')) {
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get('file');
|
||||||
|
const pasted = form.get('text');
|
||||||
|
url = String(form.get('url') || '').trim();
|
||||||
|
if (!url && typeof pasted === 'string' && pasted.trim()) {
|
||||||
|
text = pasted;
|
||||||
|
filename = String(form.get('filename') || 'pegado.txt');
|
||||||
|
} else if (!url && file && typeof file === 'object' && 'arrayBuffer' in file) {
|
||||||
|
const f = file as File;
|
||||||
|
filename = f.name || 'upload.txt';
|
||||||
|
const ext = detectSourceExt(filename, f.type);
|
||||||
|
if (!ext) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'Formatos admitidos: .pdf, .docx, .txt, .md, .csv, .json. Si el PDF es escaneado, pega el texto.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(await f.arrayBuffer());
|
||||||
|
if (buf.byteLength > 25 * 1024 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Archivo demasiado grande (máx. 25 MB)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const extracted = await extractSourceText({
|
||||||
|
filename,
|
||||||
|
mime: f.type,
|
||||||
|
buffer: buf,
|
||||||
|
});
|
||||||
|
text = extracted.text;
|
||||||
|
filename = extracted.filename;
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'No se pudo leer el archivo',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
text?: string;
|
||||||
|
filename?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
url = String(body.url || '').trim();
|
||||||
|
text = String(body.text || '');
|
||||||
|
filename = String(body.filename || 'pegado.txt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'La URL debe empezar por http:// o https://' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (compatible; 4VenturesBrandbot/1.0; +https://4ventures.es)',
|
||||||
|
Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8',
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Error al acceder a la URL: HTTP ${resp.status}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = await resp.text();
|
||||||
|
const clean = raw
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
||||||
|
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n\n')
|
||||||
|
.replace(/<br\s*[\/]?>/gi, '\n');
|
||||||
|
|
||||||
|
text = clean
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/[ \t]+/g, ' ')
|
||||||
|
.replace(/\n\s*\n\s*\n+/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const pathSegments = parsed.pathname.split('/').filter(Boolean);
|
||||||
|
const lastSeg = pathSegments[pathSegments.length - 1];
|
||||||
|
filename = lastSeg
|
||||||
|
? lastSeg.endsWith('.html')
|
||||||
|
? lastSeg
|
||||||
|
: `${lastSeg}.txt`
|
||||||
|
: `${parsed.hostname}.txt`;
|
||||||
|
} catch {
|
||||||
|
filename = 'fuente-url.txt';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `No se pudo conectar a la URL: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (text.length > 200_000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Texto demasiado largo (máx ~200k)' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, book } = await loadBook(ctx.tenantId, ctx.label);
|
||||||
|
const next = ingestSourceText(book, filename, text);
|
||||||
|
next.brand_name = next.brand_name || ctx.label;
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'No service role' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: next,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
updated_at: next.updated_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!patch.ok) {
|
||||||
|
return NextResponse.json({ error: await patch.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
type: BRANDBOOK_DOC_TYPE,
|
||||||
|
title: BRANDBOOK_DOC_TITLE,
|
||||||
|
status: 'approved',
|
||||||
|
content: next,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) {
|
||||||
|
return NextResponse.json({ error: await ins.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
tenantName: ctx.label,
|
||||||
|
actor: {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: userRole(user),
|
||||||
|
},
|
||||||
|
entityType: 'brandbook',
|
||||||
|
entityId: id,
|
||||||
|
action: 'source_upload',
|
||||||
|
summary: `Fuente · ${filename} · ${ctx.label}`,
|
||||||
|
detail: {
|
||||||
|
filename,
|
||||||
|
chars: text.length,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
field: 'source_excerpts',
|
||||||
|
from: null,
|
||||||
|
to: filename,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
revertable: false,
|
||||||
|
},
|
||||||
|
source: 'api/brandbook/upload',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
brandbook: next,
|
||||||
|
completeness: brandbookCompleteness(next),
|
||||||
|
ingested: filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
116
src/app/api/calendar/linkedin-strategy/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* GET — resolve LinkedIn strategy URL + summary for active tenant
|
||||||
|
* (brandbook URL, known slug map, optional ?url= override).
|
||||||
|
* Used by CalendarCreator / zoom to show associated strategy content.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
parseBrandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import {
|
||||||
|
loadLinkedInStrategyContext,
|
||||||
|
resolveLinkedInStrategyUrl,
|
||||||
|
} from '@/lib/linkedin-strategy';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 45;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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 override = req.nextUrl.searchParams.get('url') || '';
|
||||||
|
const postId = req.nextUrl.searchParams.get('post_id') || '';
|
||||||
|
|
||||||
|
let slug = 'cliente';
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
const bb = await restGet<{ content?: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const brandbook = parseBrandbook(bb.data?.[0]?.content);
|
||||||
|
|
||||||
|
const url = resolveLinkedInStrategyUrl({
|
||||||
|
brandbook,
|
||||||
|
slug,
|
||||||
|
overrideUrl: override,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url: null,
|
||||||
|
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||||
|
posts: [],
|
||||||
|
themes: brandbook?.linkedin_themes || [],
|
||||||
|
formats: brandbook?.linkedin_formats || [],
|
||||||
|
ctas: brandbook?.linkedin_cta || [],
|
||||||
|
message: 'Sin URL de estrategia LinkedIn en brandbook ni mapa conocido.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctxLi = await loadLinkedInStrategyContext({
|
||||||
|
url,
|
||||||
|
brandbook,
|
||||||
|
mode: 'from_strategy',
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = (ctxLi?.posts || []).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
n: p.n,
|
||||||
|
title: p.title,
|
||||||
|
meta: p.meta,
|
||||||
|
angle: p.angle,
|
||||||
|
body_preview: (p.body || '').slice(0, 500),
|
||||||
|
body: postId && p.id === postId ? p.body : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let selected = postId
|
||||||
|
? ctxLi?.posts.find((p) => p.id === postId) || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
url,
|
||||||
|
brandbook_url: brandbook?.linkedin_strategy_url || null,
|
||||||
|
posts_count: ctxLi?.posts.length || 0,
|
||||||
|
posts: posts.slice(0, 80),
|
||||||
|
selected: selected
|
||||||
|
? {
|
||||||
|
id: selected.id,
|
||||||
|
title: selected.title,
|
||||||
|
meta: selected.meta,
|
||||||
|
angle: selected.angle,
|
||||||
|
body: selected.body,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
themes: brandbook?.linkedin_themes || [],
|
||||||
|
formats: brandbook?.linkedin_formats || [],
|
||||||
|
ctas: brandbook?.linkedin_cta || [],
|
||||||
|
thought_leadership: brandbook?.thought_leadership || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
498
src/app/api/calendar/month/route.ts
Normal file
498
src/app/api/calendar/month/route.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* Create / update editorial month plans and pieces.
|
||||||
|
* POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan'|'delete_month', month, piece?, plan?, syncPipeline? }
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
asPlanObject,
|
||||||
|
emptyMonthPlan,
|
||||||
|
newPieceId,
|
||||||
|
buildCaption,
|
||||||
|
mapFormatToPipeline,
|
||||||
|
scheduledAtFromPiece,
|
||||||
|
type CalendarDoc,
|
||||||
|
} from '@/lib/calendar-documents';
|
||||||
|
import { monthLabel } from '@/lib/calendar-import';
|
||||||
|
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||||
|
import { enrichPieceFromCatalog } from '@/lib/calendar-template';
|
||||||
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function str(v: unknown, max = 4000): string {
|
||||||
|
return typeof v === 'string' ? v.trim().slice(0, max) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMonthDoc(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
tenantId: string,
|
||||||
|
month: string
|
||||||
|
): Promise<{ doc: CalendarDoc | null; plan: RrssMonthPlan | null }> {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return { doc: null, plan: null };
|
||||||
|
const docs = (await r.json()) as CalendarDoc[];
|
||||||
|
for (const d of docs) {
|
||||||
|
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
|
||||||
|
if (plan && plan.month === month) {
|
||||||
|
return { doc: d, plan: plan as unknown as RrssMonthPlan };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { doc: null, plan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlan(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
tenantId: string,
|
||||||
|
plan: RrssMonthPlan,
|
||||||
|
existingId?: string | null
|
||||||
|
) {
|
||||||
|
const title = `RRSS ${plan.label || monthLabel(plan.month)}`;
|
||||||
|
if (existingId) {
|
||||||
|
const patch = await fetch(
|
||||||
|
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(existingId)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content: plan,
|
||||||
|
status: 'approved',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!patch.ok) throw new Error(await patch.text());
|
||||||
|
const rows = await patch.json();
|
||||||
|
return Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
}
|
||||||
|
const ins = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
type: 'calendar',
|
||||||
|
title,
|
||||||
|
content: plan,
|
||||||
|
status: 'approved',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) throw new Error(await ins.text());
|
||||||
|
const rows = await ins.json();
|
||||||
|
return Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePiece(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
fallbackDate: string
|
||||||
|
): RrssPiece {
|
||||||
|
const date = str(raw.date, 10) || fallbackDate;
|
||||||
|
const title = str(raw.title, 160) || 'Sin título';
|
||||||
|
const format = str(raw.format, 40) || 'Post';
|
||||||
|
const kindRaw = str(raw.kind, 20);
|
||||||
|
const kind =
|
||||||
|
kindRaw ||
|
||||||
|
(/stor/i.test(format) ? 'story' : /reel/i.test(format) ? 'reel' : 'post');
|
||||||
|
const cleanBody = sanitizePublishCaption(
|
||||||
|
str(raw.body, 4000) || title,
|
||||||
|
{ maxLen: 4000 }
|
||||||
|
).caption;
|
||||||
|
const enriched = enrichPieceFromCatalog({
|
||||||
|
title,
|
||||||
|
platform: str(raw.platform, 40) || 'instagram',
|
||||||
|
format,
|
||||||
|
pillar: str(raw.pillar, 80),
|
||||||
|
time: str(raw.time, 5),
|
||||||
|
cta: str(raw.cta, 200),
|
||||||
|
hashtags: str(raw.hashtags, 500),
|
||||||
|
hook: str(raw.hook, 300),
|
||||||
|
body: cleanBody,
|
||||||
|
});
|
||||||
|
const slide_copy = Array.isArray(raw.slide_copy)
|
||||||
|
? (raw.slide_copy as { n?: unknown; label?: unknown; text?: unknown }[])
|
||||||
|
.map((s, i) => ({
|
||||||
|
n: Number(s.n) || i + 1,
|
||||||
|
label: s.label != null ? String(s.label).slice(0, 80) : undefined,
|
||||||
|
text: String(s.text || '').trim().slice(0, 800),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.text)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: str(raw.id, 120) || newPieceId(date, title),
|
||||||
|
date,
|
||||||
|
time: enriched.time || '10:30',
|
||||||
|
platform: (enriched.platform || 'instagram').toLowerCase(),
|
||||||
|
format: enriched.format || format,
|
||||||
|
pillar: enriched.pillar || undefined,
|
||||||
|
title: enriched.title,
|
||||||
|
hook: enriched.hook || enriched.title,
|
||||||
|
body: enriched.body || enriched.title,
|
||||||
|
cta: enriched.cta || undefined,
|
||||||
|
hashtags: enriched.hashtags || undefined,
|
||||||
|
objective_metric: str(raw.objective_metric || raw.objetivo, 200) || undefined,
|
||||||
|
status: str(raw.status, 40) || 'planned',
|
||||||
|
kind,
|
||||||
|
color:
|
||||||
|
kind === 'story'
|
||||||
|
? '#374151'
|
||||||
|
: /linkedin/i.test(enriched.platform || '')
|
||||||
|
? '#7C66B8'
|
||||||
|
: '#2071cf',
|
||||||
|
slide_copy: slide_copy?.length ? slide_copy : undefined,
|
||||||
|
reel_script: str(raw.reel_script, 6000) || undefined,
|
||||||
|
interactive_story: str(raw.interactive_story, 40) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPieceToPipeline(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
piece: RrssPiece,
|
||||||
|
month: string
|
||||||
|
) {
|
||||||
|
const caption = sanitizePublishCaption(
|
||||||
|
buildCaption(piece) || piece.title,
|
||||||
|
{ maxLen: 4000 }
|
||||||
|
).caption;
|
||||||
|
// Find existing by calendar_piece_id
|
||||||
|
const find = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&calendar_piece_id=eq.${encodeURIComponent(piece.id)}&select=id&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
|
||||||
|
const row: Record<string, unknown> = {
|
||||||
|
title: piece.title,
|
||||||
|
format: mapFormatToPipeline(piece.format, piece.kind) || 'post',
|
||||||
|
month,
|
||||||
|
caption: caption || null,
|
||||||
|
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
|
||||||
|
calendar_piece_id: piece.id,
|
||||||
|
scheduled_at: scheduledAtFromPiece(piece),
|
||||||
|
channels:
|
||||||
|
(piece.platform || '').toLowerCase() === 'facebook'
|
||||||
|
? ['facebook', 'instagram']
|
||||||
|
: ['instagram', 'facebook'],
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (existing[0]?.id) {
|
||||||
|
await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(existing[0].id)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(row),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return existing[0].id;
|
||||||
|
}
|
||||||
|
const ins = await fetch(`${base}/rest/v1/creative_pipeline`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...row,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
status: 'borrador',
|
||||||
|
publish_status: 'idle',
|
||||||
|
created_by: userId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!ins.ok) return null;
|
||||||
|
const rows = (await ins.json()) as { id: string }[];
|
||||||
|
return rows[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '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();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const action = str(body.action, 40) || 'create';
|
||||||
|
const month = str(body.month, 7);
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||||
|
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doc, plan: existing } = await loadMonthDoc(
|
||||||
|
base,
|
||||||
|
headers,
|
||||||
|
ctx.tenantId,
|
||||||
|
month
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'create') {
|
||||||
|
if (existing && !(body.force === true)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
existed: true,
|
||||||
|
plan: existing,
|
||||||
|
document_id: doc?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const seed = body.plan as RrssMonthPlan | undefined;
|
||||||
|
const plan =
|
||||||
|
seed && seed.month === month
|
||||||
|
? seed
|
||||||
|
: emptyMonthPlan(month, ctx.label);
|
||||||
|
const saved = await savePlan(
|
||||||
|
base,
|
||||||
|
headers,
|
||||||
|
ctx.tenantId,
|
||||||
|
plan,
|
||||||
|
body.force === true ? doc?.id : null
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
created: true,
|
||||||
|
plan,
|
||||||
|
document_id: saved?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'replace_plan') {
|
||||||
|
const seed = body.plan as RrssMonthPlan | undefined;
|
||||||
|
if (!seed || seed.month !== month) {
|
||||||
|
return NextResponse.json({ error: 'plan inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const saved = await savePlan(base, headers, ctx.tenantId, seed, doc?.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
plan: seed,
|
||||||
|
document_id: saved?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'upsert_piece') {
|
||||||
|
const raw = (body.piece || {}) as Record<string, unknown>;
|
||||||
|
const piece = normalizePiece(raw, `${month}-01`);
|
||||||
|
if (!piece.date.startsWith(month)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'La fecha debe pertenecer al mes' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let plan = existing || emptyMonthPlan(month, ctx.label);
|
||||||
|
const idx = plan.pieces.findIndex((p) => p.id === piece.id);
|
||||||
|
if (idx >= 0) plan.pieces[idx] = { ...plan.pieces[idx], ...piece };
|
||||||
|
else plan.pieces.push(piece);
|
||||||
|
plan.pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
plan.hero = {
|
||||||
|
...plan.hero,
|
||||||
|
chips: [`${plan.pieces.length} piezas`],
|
||||||
|
};
|
||||||
|
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
|
||||||
|
let pipelineId: string | null = null;
|
||||||
|
if (body.syncPipeline !== false) {
|
||||||
|
pipelineId = await syncPieceToPipeline(
|
||||||
|
base,
|
||||||
|
headers,
|
||||||
|
ctx.tenantId,
|
||||||
|
user.id,
|
||||||
|
piece,
|
||||||
|
month
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
piece,
|
||||||
|
plan,
|
||||||
|
document_id: saved?.id,
|
||||||
|
pipeline_id: pipelineId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete_piece') {
|
||||||
|
const pieceId = str(body.pieceId || (body.piece as { id?: string })?.id, 120);
|
||||||
|
if (!pieceId || !existing) {
|
||||||
|
return NextResponse.json({ error: 'pieza no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
const plan = {
|
||||||
|
...existing,
|
||||||
|
pieces: existing.pieces.filter((p) => p.id !== pieceId),
|
||||||
|
};
|
||||||
|
plan.hero = {
|
||||||
|
...plan.hero,
|
||||||
|
chips: [`${plan.pieces.length} piezas`],
|
||||||
|
};
|
||||||
|
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
plan,
|
||||||
|
document_id: saved?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete_month') {
|
||||||
|
if (!doc?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No hay calendario guardado para ese mes' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pieceIds = (existing?.pieces || [])
|
||||||
|
.map((p) => p.id)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
let pipelineDeleted = 0;
|
||||||
|
let pipelineUnlinked = 0;
|
||||||
|
|
||||||
|
for (const pieceId of pieceIds) {
|
||||||
|
const list = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&calendar_piece_id=eq.${encodeURIComponent(
|
||||||
|
pieceId
|
||||||
|
)}&select=id,status,publish_status`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!list.ok) continue;
|
||||||
|
const rows = (await list.json()) as {
|
||||||
|
id: string;
|
||||||
|
status?: string;
|
||||||
|
publish_status?: string;
|
||||||
|
}[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const published =
|
||||||
|
row.publish_status === 'published' ||
|
||||||
|
row.status === 'publicada' ||
|
||||||
|
row.status === 'publicado';
|
||||||
|
if (published) {
|
||||||
|
const patch = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
calendar_piece_id: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (patch.ok) pipelineUnlinked += 1;
|
||||||
|
} else {
|
||||||
|
const del = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(row.id)}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
if (del.ok) pipelineDeleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delDoc = await fetch(
|
||||||
|
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(doc.id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
if (!delDoc.ok) {
|
||||||
|
throw new Error(
|
||||||
|
(await delDoc.text()).slice(0, 300) || 'No se pudo borrar el documento'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deleted: true,
|
||||||
|
month,
|
||||||
|
document_id: doc.id,
|
||||||
|
pipeline_deleted: pipelineDeleted,
|
||||||
|
pipeline_unlinked: pipelineUnlinked,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'action desconocida' }, { status: 400 });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message.slice(0, 400) : 'Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
const month = req.nextUrl.searchParams.get('month') || '';
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||||
|
return NextResponse.json({ error: 'month requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { doc, plan } = await loadMonthDoc(base, headers, ctx.tenantId, month);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
plan,
|
||||||
|
document_id: doc?.id || null,
|
||||||
|
exists: Boolean(plan),
|
||||||
|
});
|
||||||
|
}
|
||||||
325
src/app/api/calendar/move/route.ts
Normal file
325
src/app/api/calendar/move/route.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import type { RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
import { emptyMonthPlan } from '@/lib/calendar-documents';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
type PlanDoc = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asObject(content: unknown): Record<string, unknown> | null {
|
||||||
|
let c = content;
|
||||||
|
if (typeof c === 'string') {
|
||||||
|
try {
|
||||||
|
c = JSON.parse(c);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!c || typeof c !== 'object') return null;
|
||||||
|
return c as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function piecesOf(content: Record<string, unknown>): RrssPiece[] {
|
||||||
|
return Array.isArray(content.pieces) ? (content.pieces as RrssPiece[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthTitle(ym: string): string {
|
||||||
|
const labels = [
|
||||||
|
'Enero',
|
||||||
|
'Febrero',
|
||||||
|
'Marzo',
|
||||||
|
'Abril',
|
||||||
|
'Mayo',
|
||||||
|
'Junio',
|
||||||
|
'Julio',
|
||||||
|
'Agosto',
|
||||||
|
'Septiembre',
|
||||||
|
'Octubre',
|
||||||
|
'Noviembre',
|
||||||
|
'Diciembre',
|
||||||
|
];
|
||||||
|
const m = Number(ym.slice(5, 7)) - 1;
|
||||||
|
return `RRSS ${labels[m] || ym} ${ym.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDoc(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
id: string,
|
||||||
|
plan: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
return fetch(`${base}/rest/v1/documents?id=eq.${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: plan,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep creative_pipeline.scheduled_at in sync so overlays don't snap back. */
|
||||||
|
async function syncPipelineSchedule(
|
||||||
|
base: string,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
tenantId: string,
|
||||||
|
pieceId: string,
|
||||||
|
date: string,
|
||||||
|
time?: string
|
||||||
|
) {
|
||||||
|
const hhmm = (time || '10:00').slice(0, 5);
|
||||||
|
const scheduledAt = new Date(`${date}T${hhmm}:00`).toISOString();
|
||||||
|
const pipeId = pieceId.startsWith('pipe-') ? pieceId.slice(5) : null;
|
||||||
|
|
||||||
|
const filters = pipeId
|
||||||
|
? `id=eq.${encodeURIComponent(pipeId)}`
|
||||||
|
: `calendar_piece_id=eq.${encodeURIComponent(pieceId)}`;
|
||||||
|
|
||||||
|
await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&${filters}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
scheduled_at: scheduledAt,
|
||||||
|
// Mover en calendario = reprogramar: el cron debe poder publicarla
|
||||||
|
status: 'aprobada',
|
||||||
|
publish_status: 'queued',
|
||||||
|
publish_error: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 agency = isAgencyRole(userRole(user));
|
||||||
|
if (!agency) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Solo la agencia puede mover piezas' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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(() => null)) as {
|
||||||
|
pieceId?: string;
|
||||||
|
date?: string;
|
||||||
|
snapshot?: RrssPiece;
|
||||||
|
} | null;
|
||||||
|
const pieceId = String(body?.pieceId || '').trim();
|
||||||
|
const date = String(body?.date || '').slice(0, 10);
|
||||||
|
if (!pieceId || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Falta pieceId o una fecha válida.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const docs = (list.ok ? await list.json() : []) as PlanDoc[];
|
||||||
|
|
||||||
|
let source: { doc: PlanDoc; plan: Record<string, unknown>; index: number } | null =
|
||||||
|
null;
|
||||||
|
for (const doc of docs) {
|
||||||
|
const plan = asObject(doc.content);
|
||||||
|
if (!plan) continue;
|
||||||
|
const pieces = piecesOf(plan);
|
||||||
|
const index = pieces.findIndex((p) => p.id === pieceId);
|
||||||
|
if (index >= 0) {
|
||||||
|
source = { doc, plan, index };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ym = date.slice(0, 7);
|
||||||
|
let targetDoc: PlanDoc | null = source?.doc || null;
|
||||||
|
let targetPlan: Record<string, unknown> | null = source?.plan || null;
|
||||||
|
const sourceMonth = source ? String(source.plan.month || '') : '';
|
||||||
|
|
||||||
|
// Same-document move when the piece already lives in the target month doc
|
||||||
|
// (also covers old imports where plan.month was missing).
|
||||||
|
if (
|
||||||
|
source &&
|
||||||
|
(sourceMonth === ym ||
|
||||||
|
(!sourceMonth &&
|
||||||
|
piecesOf(source.plan).some((p) =>
|
||||||
|
String(p.date || '').startsWith(ym)
|
||||||
|
)))
|
||||||
|
) {
|
||||||
|
targetDoc = source.doc;
|
||||||
|
targetPlan = source.plan;
|
||||||
|
if (!targetPlan.month) targetPlan.month = ym;
|
||||||
|
} else if (!targetDoc || sourceMonth !== ym) {
|
||||||
|
targetDoc = null;
|
||||||
|
targetPlan = null;
|
||||||
|
for (const doc of docs) {
|
||||||
|
const plan = asObject(doc.content);
|
||||||
|
if (!plan) continue;
|
||||||
|
if (String(plan.month || '') === ym) {
|
||||||
|
targetDoc = doc;
|
||||||
|
targetPlan = plan;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create month doc when dragging into a month that has no calendar yet.
|
||||||
|
if (!targetDoc || !targetPlan) {
|
||||||
|
const plan = emptyMonthPlan(ym, ctx.label || 'Cliente');
|
||||||
|
const create = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
type: 'calendar',
|
||||||
|
title: monthTitle(ym),
|
||||||
|
content: plan,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!create.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'No hay calendario para ese mes y no se pudo crear uno nuevo.',
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const created = (await create.json()) as PlanDoc[];
|
||||||
|
targetDoc = Array.isArray(created) ? created[0] : created;
|
||||||
|
targetPlan = plan as unknown as Record<string, unknown>;
|
||||||
|
if (!targetDoc?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No hay un calendario guardado para ese mes.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPieces = piecesOf(targetPlan);
|
||||||
|
const movedTime =
|
||||||
|
source?.plan
|
||||||
|
? piecesOf(source.plan)[source.index]?.time
|
||||||
|
: body?.snapshot?.time;
|
||||||
|
|
||||||
|
if (source && source.doc.id === targetDoc.id) {
|
||||||
|
targetPieces[source.index] = {
|
||||||
|
...targetPieces[source.index],
|
||||||
|
date,
|
||||||
|
platform: String(targetPieces[source.index].platform || 'instagram')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
};
|
||||||
|
} else if (source) {
|
||||||
|
const moved = {
|
||||||
|
...piecesOf(source.plan)[source.index],
|
||||||
|
date,
|
||||||
|
platform: String(
|
||||||
|
piecesOf(source.plan)[source.index]?.platform || 'instagram'
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
};
|
||||||
|
piecesOf(source.plan).splice(source.index, 1);
|
||||||
|
source.plan.pieces = piecesOf(source.plan);
|
||||||
|
if (!source.plan.month && sourceMonth) source.plan.month = sourceMonth;
|
||||||
|
const srcPatch = await patchDoc(base, headers, source.doc.id, source.plan);
|
||||||
|
if (!srcPatch.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No se pudo actualizar el mes de origen.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
targetPieces.push(moved);
|
||||||
|
} else if (body?.snapshot) {
|
||||||
|
targetPieces.push({
|
||||||
|
...body.snapshot,
|
||||||
|
date,
|
||||||
|
id: pieceId,
|
||||||
|
platform: String(body.snapshot.platform || 'instagram')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No se encontró esa pieza en el calendario.' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPlan.month = ym;
|
||||||
|
targetPlan.pieces = targetPieces.sort((a, b) =>
|
||||||
|
String(a.date).localeCompare(String(b.date))
|
||||||
|
);
|
||||||
|
|
||||||
|
const patch = await patchDoc(base, headers, targetDoc.id, targetPlan);
|
||||||
|
if (!patch.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (await patch.text()).slice(0, 240) || 'No se pudo guardar.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncPipelineSchedule(
|
||||||
|
base,
|
||||||
|
headers,
|
||||||
|
ctx.tenantId,
|
||||||
|
pieceId,
|
||||||
|
date,
|
||||||
|
movedTime
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, pieceId, date });
|
||||||
|
}
|
||||||
256
src/app/api/calendar/pieces/route.ts
Normal file
256
src/app/api/calendar/pieces/route.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
supabaseUrl,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import type { RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
import {
|
||||||
|
asPlanObject,
|
||||||
|
emptyMonthPlan,
|
||||||
|
newPieceId,
|
||||||
|
piecesFromDocs,
|
||||||
|
type CalendarDoc,
|
||||||
|
} from '@/lib/calendar-documents';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
function str(v: unknown, max = 4000): string | null {
|
||||||
|
if (typeof v !== 'string') return null;
|
||||||
|
const t = v.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
return t.slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDocs(
|
||||||
|
base: string,
|
||||||
|
headers: { apikey: string; Authorization: string },
|
||||||
|
tenantId: string
|
||||||
|
): Promise<CalendarDoc[]> {
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&type=eq.calendar&select=id,title,content`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
return r.ok ? ((await r.json()) as CalendarDoc[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agency: list RRSS calendar pieces for the active client. */
|
||||||
|
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: 'Solo agencia' }, { status: 403 });
|
||||||
|
}
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ pieces: [], warning: 'Selecciona un cliente en Contexto.' },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await loadDocs(base, headers, ctx.tenantId);
|
||||||
|
const pieces = piecesFromDocs(docs);
|
||||||
|
|
||||||
|
// Mark which are already linked in pipeline
|
||||||
|
const pipe = await fetch(
|
||||||
|
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&calendar_piece_id=not.is.null&select=id,calendar_piece_id`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
const links = pipe.ok
|
||||||
|
? ((await pipe.json()) as { id: string; calendar_piece_id: string }[])
|
||||||
|
: [];
|
||||||
|
const linked = new Set(links.map((l) => l.calendar_piece_id));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
pieces: pieces.map((p) => ({
|
||||||
|
...p,
|
||||||
|
linked_pipeline: linked.has(p.id),
|
||||||
|
pipeline_id: links.find((l) => l.calendar_piece_id === p.id)?.id || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agency upsert of a calendar piece.
|
||||||
|
* Body: { pieceId?, date, title, format?, hook?, body?, cta?, hashtags?, pillar?, time?, platform?, kind? }
|
||||||
|
* - with pieceId: update existing
|
||||||
|
* - without: create in that month's document (create month plan if missing)
|
||||||
|
*/
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
|
const pieceId = str(body.pieceId, 120);
|
||||||
|
const title = str(body.title, 300);
|
||||||
|
const date = str(body.date, 10);
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return NextResponse.json({ error: 'date (YYYY-MM-DD) requerida' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const ym = date.slice(0, 7);
|
||||||
|
|
||||||
|
const docs = await loadDocs(base, headers, ctx.tenantId);
|
||||||
|
let target = docs.find((d) => {
|
||||||
|
const plan = asPlanObject(d.content);
|
||||||
|
return plan && String(plan.month) === ym;
|
||||||
|
});
|
||||||
|
|
||||||
|
let plan = target ? asPlanObject(target.content) : null;
|
||||||
|
if (!plan) {
|
||||||
|
plan = emptyMonthPlan(ym, ctx.label) as unknown as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const pieces = Array.isArray(plan.pieces)
|
||||||
|
? ([...plan.pieces] as RrssPiece[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const patch: Partial<RrssPiece> = {
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
time: str(body.time, 8) || undefined,
|
||||||
|
platform: str(body.platform, 40) || 'instagram',
|
||||||
|
format: str(body.format, 40) || undefined,
|
||||||
|
pillar: str(body.pillar, 80) || undefined,
|
||||||
|
hook: str(body.hook, 500) || undefined,
|
||||||
|
body: str(body.body, 8000) || undefined,
|
||||||
|
cta: str(body.cta, 300) || undefined,
|
||||||
|
hashtags: str(body.hashtags, 500) || undefined,
|
||||||
|
kind: str(body.kind, 20) || undefined,
|
||||||
|
status: str(body.status, 40) || 'planned',
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved: RrssPiece;
|
||||||
|
if (pieceId) {
|
||||||
|
const idx = pieces.findIndex((p) => p.id === pieceId);
|
||||||
|
if (idx < 0) {
|
||||||
|
// Piece moved month or missing — create with same id in target month
|
||||||
|
saved = { id: pieceId, title, date, platform: 'instagram', ...patch } as RrssPiece;
|
||||||
|
// Remove from any other month docs
|
||||||
|
for (const d of docs) {
|
||||||
|
const p = asPlanObject(d.content);
|
||||||
|
if (!p || !Array.isArray(p.pieces)) continue;
|
||||||
|
const list = p.pieces as RrssPiece[];
|
||||||
|
const i = list.findIndex((x) => x.id === pieceId);
|
||||||
|
if (i < 0) continue;
|
||||||
|
if (d.id === target?.id) continue;
|
||||||
|
list.splice(i, 1);
|
||||||
|
p.pieces = list;
|
||||||
|
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: p }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pieces.push(saved);
|
||||||
|
} else {
|
||||||
|
saved = { ...pieces[idx], ...patch, id: pieceId, title, date };
|
||||||
|
pieces[idx] = saved;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saved = {
|
||||||
|
id: newPieceId(date, title),
|
||||||
|
platform: 'instagram',
|
||||||
|
status: 'planned',
|
||||||
|
...patch,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
} as RrssPiece;
|
||||||
|
pieces.push(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.pieces = pieces.sort((a, b) =>
|
||||||
|
String(a.date).localeCompare(String(b.date))
|
||||||
|
);
|
||||||
|
plan.month = ym;
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const r = await fetch(`${base}/rest/v1/documents?id=eq.${target.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: plan }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const row = {
|
||||||
|
tenant_id: ctx.tenantId,
|
||||||
|
type: 'calendar',
|
||||||
|
title: `RRSS ${(plan as { label?: string }).label || ym}`,
|
||||||
|
content: plan,
|
||||||
|
status: 'approved',
|
||||||
|
};
|
||||||
|
const r = await fetch(`${base}/rest/v1/documents`, {
|
||||||
|
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();
|
||||||
|
target = Array.isArray(created) ? created[0] : created;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
piece: {
|
||||||
|
...saved,
|
||||||
|
month: ym,
|
||||||
|
document_id: target?.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
219
src/app/api/calendar/preview/route.ts
Normal file
219
src/app/api/calendar/preview/route.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* POST { month: 'YYYY-MM' } → editorial preview for active tenant.
|
||||||
|
*/
|
||||||
|
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 { asPlanObject } from '@/lib/calendar-documents';
|
||||||
|
import { generateMonthPreview } from '@/lib/calendar-generate';
|
||||||
|
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
parseBrandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import {
|
||||||
|
pipelinePublishDay,
|
||||||
|
type PipelineCalendarRow,
|
||||||
|
} from '@/lib/pipeline-calendar';
|
||||||
|
import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
|
||||||
|
import { normalizeApprovalStatus } from '@/lib/approval-status';
|
||||||
|
import { normalizeGenOptions } from '@/lib/calendar-gen-options';
|
||||||
|
import {
|
||||||
|
collectLinkedInUsage,
|
||||||
|
loadLinkedInStrategyContext,
|
||||||
|
pickUnpublishedStrategyPosts,
|
||||||
|
resolveLinkedInStrategyUrl,
|
||||||
|
} from '@/lib/linkedin-strategy';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
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: '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 body = (await req.json().catch(() => ({}))) as {
|
||||||
|
month?: string;
|
||||||
|
avoid_titles?: string[];
|
||||||
|
gen_options?: Record<string, unknown>;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const month = String(body.month || '').slice(0, 7);
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||||
|
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const avoidTitles = Array.isArray(body.avoid_titles)
|
||||||
|
? body.avoid_titles
|
||||||
|
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
|
||||||
|
.map((t) => t.trim().slice(0, 160))
|
||||||
|
.slice(0, 40)
|
||||||
|
: [];
|
||||||
|
const genOptionsRaw = (body.gen_options || body.options || null) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| null;
|
||||||
|
const genOptions = normalizeGenOptions(genOptionsRaw || undefined);
|
||||||
|
|
||||||
|
let slug = 'cliente';
|
||||||
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
|
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
|
||||||
|
);
|
||||||
|
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
const docs = await restGet<{ content?: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&type=eq.calendar&select=content&order=created_at.desc&limit=48`
|
||||||
|
);
|
||||||
|
const history: RrssMonthPlan[] = [];
|
||||||
|
for (const d of docs.data || []) {
|
||||||
|
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
|
||||||
|
if (plan?.month && plan.month !== month && Array.isArray(plan.pieces)) {
|
||||||
|
history.push(plan as unknown as RrssMonthPlan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefer same calendar month from prior years first (e.g. 2025-11 before 2026-10)
|
||||||
|
const mm = month.slice(5, 7);
|
||||||
|
history.sort((a, b) => {
|
||||||
|
const aSame = a.month.slice(5, 7) === mm ? 0 : 1;
|
||||||
|
const bSame = b.month.slice(5, 7) === mm ? 0 : 1;
|
||||||
|
if (aSame !== bSame) return aSame - bSame;
|
||||||
|
return String(b.month).localeCompare(String(a.month));
|
||||||
|
});
|
||||||
|
|
||||||
|
const bb = await restGet<{ content?: unknown }[]>(
|
||||||
|
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
|
||||||
|
);
|
||||||
|
const brandbook = parseBrandbook(bb.data?.[0]?.content);
|
||||||
|
|
||||||
|
// Publicaciones reales en redes (pipeline): publicadas o con rastro IG
|
||||||
|
const pipe = await restGet<PipelineCalendarRow[]>(
|
||||||
|
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&select=id,title,format,month,status,caption,notes,created_at,scheduled_at,channels,publish_status,external_ref,published_at&order=created_at.desc&limit=400`
|
||||||
|
);
|
||||||
|
const publishedPosts: PublishedNetworkPost[] = [];
|
||||||
|
for (const row of pipe.data || []) {
|
||||||
|
const st = normalizeApprovalStatus(row.status);
|
||||||
|
const ref = String(row.external_ref || '').toLowerCase();
|
||||||
|
// Solo piezas ya salidas / sync IG (no borradores del pipeline)
|
||||||
|
if (
|
||||||
|
st !== 'publicada' &&
|
||||||
|
row.publish_status !== 'published' &&
|
||||||
|
!ref.startsWith('ig-')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const day =
|
||||||
|
pipelinePublishDay(row) ||
|
||||||
|
row.published_at?.slice(0, 10) ||
|
||||||
|
row.created_at?.slice(0, 10) ||
|
||||||
|
null;
|
||||||
|
publishedPosts.push({
|
||||||
|
title: row.title,
|
||||||
|
caption: row.caption,
|
||||||
|
format: row.format,
|
||||||
|
day,
|
||||||
|
month: row.month || (day ? day.slice(0, 7) : null),
|
||||||
|
status: row.status,
|
||||||
|
channels: row.channels,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkedinStrategyUrl = '';
|
||||||
|
let linkedinPrompt: string | undefined;
|
||||||
|
let linkedinStrategyPosts:
|
||||||
|
| import('@/lib/linkedin-strategy').LinkedInStrategyPost[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (genOptions.linkedinPosts > 0 || genOptions.linkedinStrategyUrl) {
|
||||||
|
linkedinStrategyUrl = resolveLinkedInStrategyUrl({
|
||||||
|
brandbook,
|
||||||
|
slug,
|
||||||
|
overrideUrl: genOptions.linkedinStrategyUrl,
|
||||||
|
});
|
||||||
|
if (linkedinStrategyUrl) {
|
||||||
|
const liCtx = await loadLinkedInStrategyContext({
|
||||||
|
url: linkedinStrategyUrl,
|
||||||
|
brandbook,
|
||||||
|
mode: genOptions.linkedinMode,
|
||||||
|
});
|
||||||
|
if (liCtx) {
|
||||||
|
linkedinStrategyPosts = liCtx.posts;
|
||||||
|
const usage = collectLinkedInUsage({
|
||||||
|
history,
|
||||||
|
publishedPosts,
|
||||||
|
avoidTitles,
|
||||||
|
});
|
||||||
|
const pick = pickUnpublishedStrategyPosts(
|
||||||
|
liCtx.posts,
|
||||||
|
usage,
|
||||||
|
genOptions.linkedinPosts || liCtx.posts.length
|
||||||
|
);
|
||||||
|
linkedinPrompt = [
|
||||||
|
liCtx.promptBlock,
|
||||||
|
`ESTADO BACKLOG ESTRATEGIA: ${pick.publishedOrPlanned.length} ya publicados o planificados · ${pick.unpublished.length} pendientes.`,
|
||||||
|
pick.publishedOrPlanned.length
|
||||||
|
? `YA USADOS (no re-agendar): ${pick.publishedOrPlanned
|
||||||
|
.slice(0, 40)
|
||||||
|
.map((p) => p.id)
|
||||||
|
.join(', ')}`
|
||||||
|
: '',
|
||||||
|
pick.unpublished.length
|
||||||
|
? `PENDIENTES (priorizar en orden): ${pick.unpublished
|
||||||
|
.slice(0, 24)
|
||||||
|
.map((p) => `${p.id} — ${p.title}`)
|
||||||
|
.join(' | ')}`
|
||||||
|
: 'Backlog agotado → crear ideas nuevas en línea de marca.',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateMonthPreview({
|
||||||
|
ym: month,
|
||||||
|
slug,
|
||||||
|
label: ctx.label,
|
||||||
|
history,
|
||||||
|
brandbook,
|
||||||
|
publishedPosts,
|
||||||
|
avoidTitles,
|
||||||
|
genOptions,
|
||||||
|
linkedinStrategyUrl: linkedinStrategyUrl || undefined,
|
||||||
|
linkedinPrompt,
|
||||||
|
linkedinStrategyPosts,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
plan: result.plan,
|
||||||
|
rationale: result.rationale,
|
||||||
|
source: result.source,
|
||||||
|
brand: slug,
|
||||||
|
brandbook: !!brandbook,
|
||||||
|
linkedin_strategy_url: linkedinStrategyUrl || null,
|
||||||
|
linkedin_posts_from_strategy: linkedinStrategyPosts?.length || 0,
|
||||||
|
linkedin_schedule: result.linkedinMeta || null,
|
||||||
|
history_months: history.length,
|
||||||
|
network_posts: publishedPosts.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user