Compare commits

157 Commits

Author SHA1 Message Date
4Ventures
812052b7eb fix(publish): no saltar piezas programadas en revisión
El cron también publica revision_agencia/programada con horario vencido; mover en calendario deja la pieza en cola.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 15:55:12 +02:00
4Ventures
4c91c4b1f4 fix(pipeline): write-back RRSS al crear creatividades
Al terminar Claude/Freepik, el cover vuelve al pipeline sin esperar solo a Canva; plantillas Canva usan canal rrss.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 15:33:48 +02:00
4Ventures
ee6e6bace1 fix(historial): registrar todos los cambios de Zona de marca
Instrumenta kit, fuentes y estilo creativo; el listado filtra por rango e incluye hoy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 14:05:17 +02:00
4Ventures
f127b0a4c4 feat(creatives): Canva Agua Miia post / story / LinkedIn
Mapea EAHYK-l1SFM, EAHYK9p_MlY y EAHYK_ylNJo por formato y plataforma en RRSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 14:00:34 +02:00
4Ventures
5de5e3c5e0 feat(creatives): plantilla Canva Agua Miia EAHYK-l1SFM
Permite pegar URL o ID de Brand Template, default por tenant y normalización al guardar para WF5 Ads/RRSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 13:51:15 +02:00
4Ventures
0742add9e6 fix(brandkit): permitir frases en léxico y formato con botones
El trim en cada tecla impedía escribir espacios en listas (léxico).
El formato de pieza usa los mismos botones que el filtro, sin dropdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 13:40:45 +02:00
4Ventures
5c76ae18bb feat(brandkit): permitir subir PDF y DOCX como fuentes
Extrae texto del PDF/DOCX en Fuentes del kit de marca para no depender
de pegar el contenido a mano.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 13:26:06 +02:00
4Ventures
77fd628078 fix(pipeline): editar formato Post/Story y bloquear horarios pasados
Permite cambiar el formato al editar una pieza, normaliza Stories→story
al guardar y rechaza programaciones en fecha/hora ya pasada.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 13:05:30 +02:00
4Ventures
c434fa5834 fix: no mezclar banners/datos ECP ↔ De Tú a Tú Formación
Aísla marcas hermanas en creatives, sync y social cache para que cada
cuenta solo muestre sus propias campañas y creatividades.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 11:44:24 +02:00
4Ventures
6763613ed4 fix(calendario): hacer visible LinkedIn en el creador desde el primer paso
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:31:54 +02:00
4Ventures
4a1abd3318 feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 04:15:51 +02:00
4Ventures
3b57cdce53 feat(calendario): opciones de generación, regenerar y publish IG/FB
Cantidades posts/stories/reels, días, mix mismo día, instrucciones,
regenerar marcadas, texto por slide/guión reel, stories interactivas
y horarios IG/FB separados o iguales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 16:36:45 +02:00
4Ventures
39b1f8ec02 feat: historial de cambios (usuario, fecha, detalle)
Tabla change_history, pantalla en Agencia OS y registro automático
al guardar o enriquecer Zona de marca.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 15:42:18 +02:00
Marcos Fargas
62a25e7236 fix(brand): sincronizacion bidireccional de linkedin_strategy_url y precarga automatica de campos b2b 2026-10-15 12:57:55 +02:00
Marcos Fargas
1f6c16a01b feat(brand): mostrar bloque de estrategia linkedin B2B en BrandOverview 2026-10-15 12:25:40 +02:00
4Ventures
89869c0e7c fix(brandbook): no meter nombres de campaña en pilares/estilos
Los insights de ads solo aportan copy humano y ángulos semánticos;
se limpian etiquetas ops ya guardadas al parsear y al enriquecer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 09:39:08 +02:00
Marcos Fargas
d049fa0f3b fix: eliminar footer rls en login y habilitar subida y sincronizacion de logo de cliente 2026-10-15 04:18:02 +02:00
4Ventures
49d3529525 fix(ui): paneles Zona de marca tipo Overview/Prohibido
Cards con eyebrow, tipografía sm legible, más aire y agrupación por
bloques en Identidad, Voz, Visual, Ads, Canales y Compliance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:59:36 +02:00
Marcos Fargas
de546e00e0 fix(context): recarga completa y refresco automatico al cambiar de cliente en Contexto 2026-10-15 03:40:27 +02:00
4Ventures
0b3f80d790 fix: aplicar layout Estilo/Brief a Zona de marca tras rebase
Nav lateral numerada + panel editable como creatividades; Auto-rellenar;
parse brandbook robusto para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:28:18 +02:00
4Ventures
3cb3ad1db1 feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS
UI con nav lateral numerada y panel editable; parse de brandbook JSON string;
enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:24:45 +02:00
Marcos Fargas
102c100427 feat(activos): anadir directorio de personas del cliente y resolver fondos oscuros en inputs 2026-10-15 03:02:58 +02:00
Marcos Fargas
4f8ea26938 feat(activos): seccion para guardar y gestionar URLs con descripcion del cliente 2026-10-15 02:27:47 +02:00
Marcos Fargas
9a4ed8b66c style(clients): rediseño visual del modal de alta de cliente con inputs claros y selector de roles 2026-10-15 02:16:04 +02:00
Marcos Fargas
f476f6a291 feat(brandbook): enriquecer seedBrandbook de Noberplast con estrategia 100% B2B de consumibles 2026-10-15 02:12:30 +02:00
Marcos Fargas
d4f28affc4 feat(brandbook): enriquecer seedBrandbook de Halal Safety con estrategia B2B completa 2026-10-15 01:56:37 +02:00
Marcos Fargas
0ddd5e5907 feat(brandbook): anadir analizador e ingesta automatica de estrategias LinkedIn B2B con IA desde URL 2026-10-15 01:34:24 +02:00
Marcos Fargas
e4a605bdbd feat(brandbook): anadir ingesta automatica de fuentes desde URL web en seccion Fuentes 2026-10-15 01:14:38 +02:00
Marcos Fargas
bcde8d8198 feat(brandbook): anadir campo de URL de LinkedIn tambien en seccion LinkedIn B2B 2026-10-15 00:46:34 +02:00
Marcos Fargas
b08c4ca00d fix(middleware): permitir x-api-key en rutas /api/ para integraciones y tests 2026-10-15 00:30:26 +02:00
Marcos Fargas
85e9f4c184 feat(brandbook): anadir ingesta y analisis automatico de blog con IA en Zona de Marca 2026-10-15 00:18:22 +02:00
Marcos Fargas
8b101c094c fix: corregir parseBrandbook para deserializar strings de documents, restaurar paneles y sincronización bidireccional total entre Zona de Marca y Creatividades Ads 2026-10-14 17:47:30 +02:00
Marcos Fargas
6e8e9ee5c8 feat: restaurar todas las secciones en Creatividades Ads con sincronización bidireccional en tiempo real con Zona de Marca 2026-10-14 17:33:37 +02:00
4Ventures
9d7d799ec1 Add RRSS auto-generation via the Ads Claude/Freepik/Canva pipeline.
Pipeline pieces can be created manually or enqueued with channel=rrss, using brandbook, calendar copy and RRSS history; n8n writes results back to creative_pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 16:22:01 +02:00
Marcos Fargas
0c1ff3cc67 fix: actualizar texto de alerta de generación indicando procesamiento en tiempo real 2026-10-14 16:17:42 +02:00
Marcos Fargas
aed73e0171 feat: centralizar ADN y estilo de marca en Zona de Marca y simplificar panel de creatividades a operativa ads 2026-10-14 14:53:39 +02:00
4Ventures
feef339c2b Import missing RRSS months from live Drive calendars.
Agua Miia and TYD had many tabs in Drive that were never in documents; add a live importer and expand the batch JOBS map so future syncs cover them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 13:31:12 +02:00
4Ventures
0e8befe69a Show all client RRSS calendars in portfolio and restore Agua Miia October.
Agency vista global listed nothing; now cards per followed client. Persist bundled Oct 2026 for Agua Miia when missing so the operative plan is not hidden by a sole November row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 13:20:18 +02:00
4Ventures
f9dddd5bf9 Add Pomelli-inspired Brand Overview to Zona de marca.
Light Centro de Mando layout with logo, fonts, colors, tagline, values, tone and completeness — edits still live in Detalles del kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 10:01:46 +02:00
4Ventures
a225f8c2a6 Allow agency to fully delete an editorial calendar month.
Removes the calendar document and linked draft pipeline pieces, with confirm-by-typing the YYYY-MM code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:07:24 +02:00
4Ventures
fd2bd9582d Let agency configure each client's left-nav modules from the ficha.
Checkboxes match the sidebar (incl. Social sub-items), with a live preview and route guard so clients cannot open disabled sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:52:55 +02:00
4Ventures
d3e383dd08 Limit client asset library to app-generated folders only.
Agency still sees the full Drive tree and portal uploads; clients only see Pipeline-creatividades / calendarios / brandbook-style paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:48:47 +02:00
4Ventures
1cf8e7c1bd Require 9:16 and 1.91:1 adaptations alongside every static 4:5 ad creative.
Generation briefs and Canva webhooks now declare all three Meta placements so WF3/WF5 deliver feed, Stories, and landscape from one Freepik base.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:03:40 +02:00
4Ventures
c8608f36f5 Add Dokploy scripts to apply and verify unified approval_status SQL.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 16:49:18 +02:00
4Ventures
f80292cc1b Show agency creatives board when a client is selected in Contexto.
Creatividades Ads was always rendering the client approval UI, so agency never saw Pedir relevo / kanban on that route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 16:37:51 +02:00
4Ventures
2b57b93220 Allow keeping selected calendar pieces/days and regenerating the rest.
Agency can mark good stories, keep only those days, or request a partial v2 for unmarked slots without replacing the whole month.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:57:13 +02:00
4Ventures
714f250eca Feed editorial calendar generation with past calendars and live network posts.
Brandbook plus calendar history and published pipeline/IG pieces now shape series mix and Claude strategist prompts without copying old titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:53:27 +02:00
4Ventures
3afec6402f fix calendar→pipeline sync to use unified borrador status.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:30:22 +02:00
4Ventures
46492c3873 Unify Ads/RRSS approval flow and add personalized Meta creative regen.
Agency validates first then client; Meta copy is 5+5+1 with on-image vs feed layers, emojis on feed, and partial v2 by image/hook/support/copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 13:17:36 +02:00
4Ventures
4bc9ed7820 Always show Pipeline RRSS under Social when Social is visible.
Remove extra module gates so the editorial pipeline link cannot be filtered out of the sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:59:13 +02:00
4Ventures
7819448edc Add Pipeline RRSS under Social with the existing editorial board.
Keep Creatividades Ads for paid Meta approval and surface organic pipeline again as a clear Social sub-nav link.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 09:55:46 +02:00
Marcos Fargas
b13270d9ac feat(creatives): implement 7 system improvements (Canva recompose, Slack actions, reject feedback loop, 9:16 stories, Meta campaign auto-detect, Claude Vision QA, angle analytics) 2026-10-13 02:41:02 +02:00
Marcos Fargas
142933a654 feat(creatives): add configurable per-client creative style and skill builder in UI and API 2026-10-13 01:44:44 +02:00
Marcos Fargas
2a1629cda8 feat(webhooks): dispatch async generation_request.created and creative.created events to n8n with 60s retry 2026-10-13 01:17:26 +02:00
Marcos Fargas
66d34853e6 feat(canva): support asset_final, created_by, include=client filters in GET and merge meta in PATCH 2026-10-12 22:40:23 +02:00
Marcos Fargas
ab64ead6c3 feat(brief): add product and landing_url to brief, map from campaign_products and inherit from source creative 2026-10-12 21:15:43 +02:00
Marcos Fargas
9276dba00d fix(auth): support array bodies in integration authentication 2026-10-12 20:45:56 +02:00
Marcos Fargas
317a3aa46d feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes 2026-10-12 20:32:15 +02:00
Marcos Fargas
34ce46774d feat(creatives): implement agency and client views (Tasks 6 & 7) with fatigue panel and Claude queue 2026-10-12 20:08:34 +02:00
Marcos Fargas
7b604c68ca fix(creatives): remove website col from tenant select 2026-10-12 19:21:48 +02:00
Marcos Fargas
cf34868bc3 feat(creatives): implement WF4 integrations, GET include=client, asset_final check and HMAC webhook 2026-10-12 19:18:21 +02:00
Marcos Fargas
487895a420 fix(integrations): allow querying queue without tenant_id and resilient tenant resolution in PATCH 2026-10-12 18:41:44 +02:00
Marcos Fargas
a7e2723a3e feat(integrations): add skills endpoint, enrich creative context with client info, and support meta in creatives 2026-10-12 18:40:11 +02:00
Marcos Fargas
52cebab3e6 feat(integrations): support history_days in fatigue candidates and ensure data envelope for WF2 2026-10-12 18:17:23 +02:00
Marcos Fargas
a17b1eae56 docs(integrations): complete API specification and n8n HMAC verification snippet 2026-10-12 13:47:47 +02:00
Marcos Fargas
958c59a8ec feat(creatives): task 5 - approval actions, outgoing webhooks with HMAC, and publish patch 2026-10-12 13:44:54 +02:00
Marcos Fargas
7b702cd4cc fix(integrations): support safe default keys when Dokploy env vars are not injected 2026-10-12 13:39:14 +02:00
Marcos Fargas
0f9f03fad5 feat(creatives): task 4 - claude integration endpoints (generation-requests, creatives, context, events) 2026-10-12 13:35:50 +02:00
Marcos Fargas
9f1721c921 fix(integrations): support parsedBody in authenticateIntegrationRequest 2026-10-12 13:22:38 +02:00
Marcos Fargas
b3936932d3 fix: import path without ts extension for standard Next compiler 2026-10-12 13:19:13 +02:00
Marcos Fargas
503f8322d1 fix(integrations): ensure explicit string array type for metaAdIds 2026-10-12 13:17:36 +02:00
Marcos Fargas
e650c9971e feat(creatives): task 3 - ingest and fatigue endpoints (clients, metrics/daily, candidates, mark) 2026-10-12 13:15:48 +02:00
Marcos Fargas
62d1f3a5f6 feat(creatives): task 2 - integrations auth and visibility scope with sanitization 2026-10-12 13:13:46 +02:00
Marcos Fargas
f08bcf2bb8 feat(creatives): task 1 - migration, models and seed for creatives module 2026-10-12 13:10:34 +02:00
4Ventures
3cb3296518 Load the full free Google Fonts gallery in Zona de marca typography.
Browse ~1950 OFL fonts with search, category filters and live preview; one click adds a font to the brand kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 02:00:09 +02:00
4Ventures
1715b0ac67 Expand Zona de marca into a full brand kit with asset uploads.
Expert sections plus logos, fonts, color tokens and file kit so ads, calendars and B2B content share one brand system.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 01:35:08 +02:00
4Ventures
e49111f63e Add Zona de marca brandbooks and strategist calendar generation.
Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 01:05:15 +02:00
4Ventures
59f42eafe0 Anchor calendar previews to each client historical editorial line.
Remix same-month prior years and recurring series instead of generic season placeholders like Black Friday.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 23:57:41 +02:00
4Ventures
8723753066 Associate Detuatuformacion Meta ad account with De Tú A Tú.
Support multiple Meta ad accounts per tenant in creatives/sync UI so ECP and Detuatuformacion can both be linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 10:20:00 +02:00
4Ventures
f316497650 Add in-app editorial calendar creator with month zoom and preview.
Agency can pick a month, generate a brand/seasonality preview, and add/edit/delete pieces with the same fields as the creative pipeline, optionally syncing drafts there.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 18:12:10 +02:00
4Ventures
501f8203e8 Replace BPS Excel stub with multi-client calendar template.
Official download now generates a blank workbook with catalog autofill, notas_internas ignored on import, and the same template for every tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:38:38 +02:00
4Ventures
025fc85175 Harden Meta publish against dirty captions and IG duplicates.
Strip production notes before posting, persist each network id immediately, refuse IG republish if delete fails, and place FB photos via feed for better timeline visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 17:17:07 +02:00
4Ventures
67969b12cb Add batch importer for Drive RRSS editorial calendars.
Import requested months for BPS, Brava, TYD, Halal, Noberplast and 4Ventures from the shared Excel export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 16:43:07 +02:00
4Ventures
71110915af Ship CRM health, cohort CAC, GHL/Shopify sync, and agency alerts.
Add WooCommerce health monitoring, 60d cohort CAC in CRM and portfolio,
incremental GHL upsert, Shopify orders→deals, and daily health-alert cron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 12:28:38 +02:00
4Ventures
4baf256750 Make Clientify CRM sync daily and reliable via incremental upsert.
Webhooks alone miss WooCommerce deals; replace wipe+cap sync with lookback upsert and a dedicated Dokploy cron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 11:02:51 +02:00
4Ventures
b4e843762e Show Google Ads platform leads separately from CRM-sourced leads.
Clarifies BPS case: Google Ads conversions exist while Clientify has no source=google contacts; ventas stay on ecommerce until UTM/gclid maps the deal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:35:11 +02:00
4Ventures
b53161dc4d Include paid ad channels with spend in CRM % a cliente table.
Google (and other channels with ads but no CRM-sourced leads) now appear with 0 leads so investment and CAC stay visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:11:30 +02:00
4Ventures
8ef21d98d7 Show global and per-channel CAC in CRM and Métricas.
Reuse won deals + ad spend for every tenant: CAC column in the metrics channel table, and a CRM hero + channel breakdown with the same formula.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 09:48:48 +02:00
4Ventures
e3fe157fa5 Harden per-client isolation for calendars, Meta, creatives, and KPIs.
Validate view-as tenants, scope service-role mutations by tenant_id, add documents RLS, refuse portfolio-wide data dumps, and block Meta publish when a Page/IG is linked to another client.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:57:21 +02:00
4Ventures
83de3fb3aa Fix calendar drag snap-back, IG filter, and default to 2026.
Prefer the latest year in the archive, normalize Instagram casing so stories show under IG/FB, sync pipeline dates on move, and stop stale plan re-renders from undoing a successful drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:29:49 +02:00
4Ventures
2759aa87f6 Enable live Meta caption updates and partial IG/FB deletes from the pipeline.
Facebook edits in place when possible; Instagram deletes and republishes. Trash and delete-meta no longer block when only one network succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:46:48 +02:00
4Ventures
26dc411647 Harden scheduled Meta publish and auto-link Drive for pipeline creatives.
Schedule save auto-approves and publishes when due; cron recovers stale jobs every 5m. Drive sync auto-matches client folders under Clientes when not linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:33:41 +02:00
4Ventures
0d65614518 Fix Drive media PATCH body type for Next build.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:48:44 +02:00
4Ventures
233d3f202e Mirror pipeline creatives into each client's Drive folder tree.
On upload, create Pipeline-creatividades_{slug}/YYYY/MM under the linked client folder and name files DD-MM-YYYY_formato_hook[-N].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:44:07 +02:00
4Ventures
5b895d830b Add creative image upload to the new pipeline piece form.
Agency can pick cover (and carousel slides) when creating a piece instead of only after the card exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 12:14:14 +02:00
4Ventures
b4d86e1e28 Enable in-place pipeline edits with calendar sync and timed publish cron.
Agency can edit title/copy on existing cards (optional RRSS sync); Dokploy runs pipeline-publish every 10 minutes with daily-sync as backup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:33:10 +02:00
4Ventures
ade2762b01 Wire Meta IG/FB publish from pipeline with feed view and delete.
Agency can publish now or on schedule, remove live posts from Meta, and browse pieces newest-first like an IG board.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:18:15 +02:00
4Ventures
c2bbbfcd06 Link pipeline creatives to RRSS calendar pieces with pick-or-create UI.
Agency can choose a scheduled calendar post to prefill title, copy, pillar and date, or create/edit one in the calendar while adding it to the pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:01:11 +02:00
4Ventures
12ecde1b3b Add agency-only schedule fields and queue for pipeline creatives.
Approved pieces can be timed for IG/FB from the agency board; cron queues due jobs and calendar uses scheduled_at. Meta publish comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 17:54:00 +02:00
4Ventures
392af11060 Make uploaded calendars live with drag-and-drop days and readable chips.
Posts persist to the tenant calendar document when moved; contrast on the month grid no longer uses dark text on dark fills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 17:07:09 +02:00
4Ventures
91380e2285 Revert unsolicited creative pipeline review-only changes.
Restore CreativesPipeline on /creatives and the previous board/API behavior (published pieces visible again; no forced reject note).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:54:38 +02:00
4Ventures
d073df12d1 Treat Brava-style SEMANA/Story/POST Excel as the official calendar template.
Harden week-grid parsing (placeholders, status rows, month wrap) and ship an October-shaped plantilla for agency uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:46:17 +02:00
4Ventures
b395243ce7 Add CSV/Excel calendar upload that maps posts into the monthly RRSS view.
Agency can import editorial calendars per tenant; pieces land on each day with the same month grid and day zoom as existing plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 16:26:34 +02:00
4Ventures
ed275f475a Fix leftover lookback constant so the Ads/Métricas build compiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:37:36 +02:00
4Ventures
a5bfec698b Align Ads and Métricas on the same paginated ad_daily warehouse.
Ad Tracker only loaded 2000 newest rows, so Brava showed 0 € while Métricas summed the full month. Both now page the same table for the selected range.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:33:06 +02:00
4Ventures
fac9867202 Fix calendar plan merge typing so production build can compile.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:25:24 +02:00
4Ventures
965a58540e Ship monthly goals, calendar creative zoom, and legal pages.
Editable targets vs actual, pipeline review-only with client reject notes, and published pieces opening from the calendar day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 15:17:15 +02:00
4Ventures
d411de0086 Split Google Ads copy into titles, long titles and descriptions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 13:44:38 +02:00
4Ventures
b9ddc7b806 Show every Google PMax text, image and YouTube asset in Ads zoom.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 13:38:24 +02:00
4Ventures
8db642fa2a Label LinkedIn/TikTok as a saved profile, not live social metrics.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 13:19:10 +02:00
4Ventures
dfb44d240e Align CRM, Ads, Social and Organic dashboards with the metrics visual system.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 13:12:27 +02:00
4Ventures
4d662430c0 Redesign the metrics scorecard for scanability and clearer comparisons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 13:01:10 +02:00
4Ventures
f19a54ca42 Drop the Google Ads campaign table from the metrics scorecard.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:53:20 +02:00
4Ventures
8a9d92cb6b Mark GEO as coming soon and slim the SEO menu to live data.
Hide duplicate GSC/GA4 views and the manual day-load CTA so Orgánico matches Search Console, Analytics, and Métricas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:40:53 +02:00
4Ventures
81b424bd2f Remove the editorial calendar publish button.
Agency will load new months from files, not a portal CTA that copied another client’s plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:30:54 +02:00
4Ventures
ead310e783 Keep editorial calendars private to each tenant.
Stop seeding Agua Miia’s October plan for every client and restrict the portal publish action to that tenant only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:25:47 +02:00
4Ventures
5b21ffce94 Fix Windsor JSX so the metrics build can ship.
The second panel was still live because Next failed on an unwrapped ternary; the unified scorecard can deploy now.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:21:45 +02:00
4Ventures
ed52bf99c4 Present metrics as one guided scorecard, not two dashboards.
Split ads, CRM and web into numbered blocks, drop the Windsor second panel, and keep Google campaigns as a single extra table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:11:50 +02:00
4Ventures
0ad89e88e5 Fix GoHighLevel opportunity pagination and contact dedupe.
GHL search rejects skip; page+location_id is required so nightly CRM sync can complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 12:06:27 +02:00
4Ventures
487e2c7a2b Add GoHighLevel CRM sync for tenant locations.
Warehouse leads/deals from GHL contacts and opportunities so Métricas and CRM can use the same scorecard as Clientify.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:57:02 +02:00
4Ventures
fb8adab162 Unify metrics into a single dashboard scorecard.
Fold CRM and organic KPIs into Métricas and hide the duplicate Windsor strip when embedded.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 11:46:50 +02:00
4Ventures
ace73c2699 fix: unify creatives into Pipeline and hide Panel nav
Absorb client approval, month filters and editorial pieces into Pipeline; redirect /panel-cliente and remove duplicate menu entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 10:16:15 +02:00
4Ventures
3ddf722912 fix: Meta previews vertical by default; show all Google ad formats
Meta cards use a 9:16 frame (carousel 1:1 then 4:5). Google gallery balances Search/PMax/Display and surfaces PMax multi-format thumbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:22:01 +02:00
4Ventures
78bd80079d fix: bind Windsor KPIs block to Contexto tenant
Stop hardcoding Blue Parrot branding/connectors so switching clients
in the portfolio selector updates labels and integration chips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 18:10:17 +02:00
4Ventures
ce6dc4e0f0 fix: split Google Ads creatives by Search / PMax / Display
Fetch display/RDA assets, classify Brand vs Generic Search, and group
the Ad Tracker gallery so previews are not a single undifferentiated image.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 17:56:05 +02:00
4Ventures
f3380b9650 chore: remove temporary auth debug endpoint after login fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 17:17:53 +02:00
4Ventures
c82cc32278 fix: add temporary /api/debug/session to diagnose auth cookies
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 16:36:51 +02:00
4Ventures
1f9bc38af0 fix: use getSession in middleware so login works offline of Kong
Edge getUser() could not reach Auth, so a valid cookie still looked
logged-out. Route on the local session cookie instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 16:27:14 +02:00
4Ventures
bb5f721b38 fix: restore login by dropping broken Supabase Kong rewrite
Middleware getUser() could not validate the session cookie via the
internal Docker hostname, so auth succeeded in the browser but kept
redirecting back to /login stuck on "Verificando".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 16:14:00 +02:00
4Ventures
a6509bb89c fix: Google Ads via marketing@ OAuth first; harden Clientify webhooks
Prefer agency google_drive+ads token over stale env refresh to avoid
PERMISSION_DENIED, and accept Clientify contact_id/deal_id payload aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 12:26:48 +02:00
Marcos Fargas
ec7f1f05de chore: remove 'datos del ... al ...' subtitle from channel headers in KPIs page 2026-10-05 01:13:19 +02:00
Marcos Fargas
821519611d fix: use exact Windsor.ai field names for LinkedIn Pages organic metrics 2026-10-05 01:05:51 +02:00
Marcos Fargas
777c4f9dfa feat: connect Google My Business and LinkedIn metrics via Windsor.ai API 2026-10-05 00:53:10 +02:00
Marcos Fargas
12f9c3cd1e chore: place KPIs tab right after Resumen in navigation menu 2026-10-05 00:44:22 +02:00
Marcos Fargas
36d9e059a3 feat: map instagram and facebook metrics and published posts from integrations social_cache 2026-10-05 00:38:15 +02:00
Marcos Fargas
6e81302c89 fix: use service role for fetching and correct ga4/gsc column mappings 2026-10-04 23:51:28 +02:00
Marcos Fargas
669b9dd056 fix: correct column names for ad_daily query and parsing 2026-10-04 23:36:31 +02:00
Marcos Fargas
d56ba63f17 fix: use dynamic tenant_id from user session for html dashboard 2026-10-04 23:30:30 +02:00
Marcos Fargas
fff5bf1ca2 fix: complete KPI HTML dashboard integration (mock window.claude, add ig.fol, map piezas, pass wData) 2026-10-04 23:18:30 +02:00
Marcos Fargas
630a70c533 fix: add missing fb and li sources to K object for html dashboard 2026-10-04 22:08:32 +02:00
Marcos Fargas
3b8c41c7fb fix: add type definitions to fetch wrapper to fix build error 2026-10-04 22:03:30 +02:00
Marcos Fargas
f70d366a7f chore: trigger redeploy to pick up type fixes 2026-10-04 22:01:56 +02:00
Marcos Fargas
6bfd8a117f feat: add blue parrot static html kpis dashboard connected to windsor data 2026-10-04 21:16:35 +02:00
Marcos Fargas
38bb161531 fix: use custom fetch wrapper in supabase ssr to route traffic internally and avoid fetch failed edge errors 2026-10-04 20:43:30 +02:00
Marcos Fargas
4bf3ecb744 Revert "fix: use internal docker network url for server-side supabase client to avoid fetch errors"
This reverts commit 5124c1d8a4.
2026-10-04 20:38:29 +02:00
Marcos Fargas
5124c1d8a4 fix: use internal docker network url for server-side supabase client to avoid fetch errors 2026-10-04 20:32:24 +02:00
Marcos Fargas
e07f21c674 feat(kpis): agregar seccion Windsor.ai de blueparrotschool.com debajo de Cruce por canal 2026-10-04 19:58:33 +02:00
Marcos Fargas
e84310ec52 fix(build): carga de fuente sin dependencia externa en docker build 2026-10-04 19:34:02 +02:00
Marcos Fargas
2f6e50d5a7 feat(panel-cliente): soporte de filtrado mensual, histórico instagram ene-sep 2026 y enlaces a publicaciones 2026-10-04 19:31:11 +02:00
Marcos Fargas
a3a2e4b62b fix(build): allow fallback select without link_clicks and ensure unique constraint in creative_pipeline 2026-10-04 01:07:44 +02:00
Claude
6566b42037 feat: script de carga para las 33 creatividades de Blue Parrot (oct26-ene27)
scripts/seed_blue_parrot_pipeline.ts sube portada + slides de carrusel al
bucket tenant-assets y crea/actualiza las filas en creative_pipeline
(upsert por tenant_id+external_ref, así se puede re-ejecutar sin duplicar).

Metadata en scripts/blue-parrot-pipeline-seed.json: títulos reales
extraídos de las 33 piezas ya diseñadas (oct-01..oct-12, nov-01..nov-07,
dic-01..dic-07, ene27-01..ene27-07). Estado inicial por defecto:
revision_interna (parametrizable con --status).

Requiere ejecutarse en el propio VPS (con las credenciales reales de
Supabase ya configuradas ahí) contra la carpeta descomprimida del ZIP de
imágenes — no incluye las imágenes ni credenciales en el repo.

Añade dotenv y tsx como devDependencies (ya usados por otros scripts del
repo pero no declarados en package.json).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
2026-10-04 00:25:25 +02:00
Claude
ed18c9d318 feat: panel de creatividades (pipeline editorial) en /dashboard/panel-cliente
Nueva tabla creative_pipeline (separada de creatives, que es solo para
anuncios aprobados Meta/Google): Propuesta -> En diseño -> Revisión
interna -> Aprobado/Rechazado -> Publicado, con RLS por tenant.

- sql/creative_pipeline.sql: esquema + políticas RLS
- /api/creative-pipeline: CRUD con transiciones restringidas (el cliente
  solo puede aprobar/rechazar una pieza en revisión interna; el resto de
  movimientos y la creación son solo agencia)
- /api/creative-pipeline/upload + /media: portada y slides de carrusel
  sobre el bucket privado tenant-assets ya existente
- CreativePipelineBoard: tablero agrupado por mes, con subida de imagen
  inline y acciones según rol
- Alta del módulo "Panel de Creatividades" en tenant-modules y sidebar

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqGTGPznxTp8mwMg6Yv4Ew
2026-10-04 00:22:03 +02:00
4Ventures
e3180ceb51 feat: sync Clientify CRM + botón Sincronizar por pestaña
El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 15:34:40 +02:00
4Ventures
c11ec20f26 fix: Meta sync usa token agency_oauth (no solo env BPS)
El META_ACCESS_TOKEN de Dokploy solo tenía acceso a BPS; el de agency_oauth cubre el resto. Probamos candidatos y priorizamos agency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 17:33:17 +02:00
4Ventures
1a58af129d chore: scripts Dokploy/BPS (cron secret, Ads MCC, sync y probes)
Incluye utilidades de ops para CRON_SECRET, login customer Google Ads, sync de seguimiento BPS y probes de conexiones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 16:48:44 +02:00
4Ventures
8c5acb626f feat: columna ad_daily.link_clicks (Meta clics enlace reales)
Persistimos link_clicks en warehouse (con backfill desde __lc), sync escribe la columna y creatividades la leen sin romper si falta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 10:50:55 +02:00
4Ventures
82a910330d perf: acortar lookback KPIs y unificar fallback Google warehouse
Quita el suelo de 700 días (basta desde ene-2025) y deja buildGoogleCardsFromDaily solo en ad-creatives-google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:36:41 +02:00
266 changed files with 68776 additions and 2970 deletions

2
.gitignore vendored
View File

@@ -60,3 +60,5 @@ yarn-error.log*
*.local
coverage/
.turbo/
.ops/gitea-deploy/

370
docs/API_INTEGRACIONES.md Normal file
View 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
}
};
```

View File

@@ -67,6 +67,18 @@ Endpoints individuales (mismo secret):
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
- `/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).

5
next-env.d.ts vendored Normal file
View 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.

View File

@@ -3,6 +3,7 @@ const nextConfig = {
output: 'standalone',
reactStrictMode: true,
poweredByHeader: false,
serverExternalPackages: ['xlsx'],
async headers() {
return [
{

3239
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,15 +17,18 @@
"next": "15.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2"
"tailwind-merge": "^3.0.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.20",
"dotenv": "^18.0.5",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.17",
"tsx": "^4.23.15",
"typescript": "^5.7.3"
}
}

Binary file not shown.

View 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 &amp; 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>&gt; 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
View 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);
});

View 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);
});

View 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);
});

View 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" }
]

File diff suppressed because it is too large Load Diff

View 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": []
}
}

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Apply sql/ad_daily_link_clicks.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "ad_daily_link_clicks.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/adlc.b64 /tmp/adlc.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/adlc.b64", 400)
run(
"base64 -d /tmp/adlc.b64 > /tmp/adlc.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adlc.sql; echo EXIT:$?",
12000,
)
run(
"psql -U postgres -d postgres -c "
"\"SELECT column_name,data_type FROM information_schema.columns "
"WHERE table_schema='public' AND table_name='ad_daily' "
"AND column_name='link_clicks';\" ; echo COL_DONE",
4000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text and "link_clicks" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-2000:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

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

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

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

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

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

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

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

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

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

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

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

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

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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
View 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);
});

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

View 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);
});

View 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);
});

View 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);
});

View 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);
});

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

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

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

View 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);
});

View 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);
});

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

View 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);
});

View 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;
});

View 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);
});

View 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);
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</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);
});

View 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);
});

View 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
View 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);
});

View 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
View 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."

View 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.

View 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?

View 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
View 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
View 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
View 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`.

View File

@@ -1,3 +1,18 @@
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
ALTER TABLE public.ad_daily
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;
-- Backfill from legacy Meta marker stored in ad_name (`__lc:N`).
UPDATE public.ad_daily
SET link_clicks = (regexp_match(ad_name, '^__lc:([0-9.]+)$'))[1]::numeric
WHERE platform = 'meta'
AND ad_name ~ '^__lc:'
AND (link_clicks IS NULL OR link_clicks = 0);
-- Google: link clicks ≈ all clicks at campaign level.
UPDATE public.ad_daily
SET link_clicks = clicks
WHERE platform = 'google'
AND (link_clicks IS NULL OR link_clicks = 0)
AND clicks IS NOT NULL
AND clicks > 0;

View 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
View 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
View 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

View 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.';

View 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.';

View 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
View 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.';

View 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
View 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;

View File

@@ -11,3 +11,6 @@ comment on column public.targets.spend_target is
'Presupuesto ads objetivo del mes (ppto)';
comment on column public.targets.spend_actual is
'Gasto ads real del mes (opcional; si null se calcula de ad_daily)';
create unique index if not exists targets_tenant_month_uidx
on public.targets (tenant_id, month);

View 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;

View File

@@ -14,6 +14,7 @@ import {
buildGoogleCardsFromDaily,
fetchGoogleCreativeCards,
} from '@/lib/ad-creatives-google';
import { filterRowsForTenantBrand } from '@/lib/tenant-brand-aliases';
export const dynamic = 'force-dynamic';
@@ -76,24 +77,47 @@ export async function GET(req: NextRequest) {
const agency = isAgencyRole(userRole(user));
let daily: AdDailyRow[] = [];
const selectWithLc =
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
const selectNoLc =
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
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) {
const tid = encodeURIComponent(ctx.tenantId);
const d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads&order=date.desc&limit=5000`
let d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000`
);
if (!d.ok && /link_clicks/i.test(String(d.error || ''))) {
d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectNoLc}&order=date.desc&limit=5000`
);
}
daily = d.data || [];
} else {
const { data } = await supabase
let q: any = await supabase
.from('ad_daily')
.select(
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'
)
.select(selectWithLc)
.eq('tenant_id', ctx.tenantId)
.gte('date', since)
.lte('date', until)
.order('date', { ascending: false })
.limit(5000);
daily = (data as AdDailyRow[]) || [];
if (q.error && /link_clicks/i.test(q.error.message || '')) {
q = await supabase
.from('ad_daily')
.select(selectNoLc)
.eq('tenant_id', ctx.tenantId)
.gte('date', since)
.lte('date', until)
.order('date', { ascending: false })
.limit(5000);
}
daily = (q.data as AdDailyRow[]) || [];
}
daily = filterRowsForTenantBrand(tenantSlug, daily);
const warnings: string[] = [];
let metaAds: AdCreativeCard[] = [];
@@ -107,7 +131,7 @@ export async function GET(req: NextRequest) {
// Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
if (mode === 'fast') {
if (platform === 'meta' || platform === 'all') {
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
}
if (platform === 'google' || platform === 'all') {
@@ -136,6 +160,7 @@ export async function GET(req: NextRequest) {
since,
until,
limit: 16,
tenantSlug,
}),
fetchMetaAccountClickSplit({
tenantId: ctx.tenantId,
@@ -145,7 +170,7 @@ export async function GET(req: NextRequest) {
]);
metaAds = meta.ads.length
? meta.ads
: buildMetaWarehouseCards(daily, since, until, 16);
: buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit =
clickSplit || metaClickSplitFromDaily(daily, since, until);
if (
@@ -155,7 +180,7 @@ export async function GET(req: NextRequest) {
warnings.push(meta.warning);
}
} catch {
metaAds = buildMetaWarehouseCards(daily, since, until, 16);
metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit = metaClickSplitFromDaily(daily, since, until);
}
}
@@ -167,7 +192,7 @@ export async function GET(req: NextRequest) {
dailyRows: daily,
since,
until,
limit: 24,
limit: 80,
});
googleAds = google.ads.length
? google.ads

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole as resolveUserRole } from '@/lib/agency';
export async function POST(req: NextRequest) {
try {
@@ -10,8 +11,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 });
}
const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client';
if (userRole !== 'admin' && userRole !== 'team') {
if (!isAgencyRole(resolveUserRole(user))) {
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'],
['tag_manager', 'Tag Manager'],
['clientify', 'Clientify'],
['gohighlevel', 'GoHighLevel'],
['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'],
['google_drive', 'Google Drive'],
['wordpress', 'WordPress'],
['prestashop', 'PrestaShop'],
['shopify', 'Shopify'],
['woocommerce', 'WooCommerce'],
['instagram', 'Instagram'],
['facebook', 'Facebook Page'],
['tiktok', 'TikTok'],

View File

@@ -118,6 +118,7 @@ export async function PATCH(req: NextRequest) {
typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined;
const enabled =
typeof body.enabled === 'boolean' ? body.enabled : undefined;
const pit = typeof body.pit === 'string' ? body.pit.trim() : '';
if (!tenantId || !provider) {
return NextResponse.json(
@@ -193,7 +194,23 @@ export async function PATCH(req: NextRequest) {
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(
{ error: 'status, enabled o externalAccountId requeridos' },
{ status: 400 }

View File

@@ -7,9 +7,16 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
import { portfolioSpendWeightedCac } from '@/lib/metrics';
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). */
export async function GET() {
const supabase = await createClient();
@@ -32,23 +39,33 @@ export async function GET() {
}
const base = supabaseUrl();
const [tenantsRes, integRes, alertsRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
]);
const since30 = dayOffset(-30);
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
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() : [];
// Fallback if agency_followed column missing
@@ -70,6 +87,34 @@ export async function GET() {
}
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 = {
tenant_id: string;
provider: string;
@@ -106,11 +151,19 @@ export async function GET() {
for (const p of INTEGRATION_PROVIDERS) {
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 {
...t,
agency_followed: t.agency_followed !== false,
integrations: rows,
byProvider,
economics: {
spend30,
deals30,
cac30,
},
stats: {
connected,
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({
portfolio,
alerts,
providers: INTEGRATION_PROVIDERS,
schemaReady: !integMissing,
portfolioCac30: portfolioCac,
economicsWindowDays: 30,
warning: integMissing
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
: null,

View 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);
}

View File

@@ -12,18 +12,30 @@ import {
sanitizeFileName,
serviceSupabase,
} 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 runtime = 'nodejs';
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
const ALLOWED = new Set([
const MAX_LOGO = 15 * 1024 * 1024; // 15 MB
const ALLOWED_MIMES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/svg+xml',
'image/svg',
'image/x-png',
'image/pjpeg',
]);
const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']);
async function requireAgency() {
const supabase = await createClient();
@@ -64,18 +76,36 @@ export async function POST(req: NextRequest) {
}
if (file.size > MAX_LOGO) {
return NextResponse.json(
{ error: 'Logo máximo 2 MB' },
{ error: 'Logo máximo 15 MB' },
{ 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(
{ error: 'Usa PNG, JPG, WEBP o SVG' },
{ error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' },
{ 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();
if (!bucketOk.ok) {
return NextResponse.json(
@@ -90,15 +120,51 @@ export async function POST(req: NextRequest) {
}
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 sha256 = createHash('sha256').update(buf).digest('hex');
const { error: upErr } = await sb.storage
.from(ASSETS_BUCKET)
.upload(path, buf, { contentType: mime, upsert: true });
.upload(path, buf, { contentType: resolvedMime, upsert: true });
if (upErr) {
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
const logoUrl = `storage:${path}`;
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({
logoUrl,
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,

View 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,
});
}

View 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 });
}

View File

@@ -2,6 +2,10 @@ 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 {
filterDriveEntriesForClientRoot,
isAppGeneratedDriveFolderName,
} from '@/lib/assets-client-scope';
import {
findClientesRootFolder,
getDriveDownloadUrl,
@@ -15,7 +19,33 @@ export const dynamic = 'force-dynamic';
const FOLDER_MIME = 'application/vnd.google-apps.folder';
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */
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) {
const supabase = await createClient();
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();
if (downloadId) {
@@ -52,6 +82,15 @@ export async function GET(req: NextRequest) {
{ 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);
return NextResponse.json({ url });
} catch (e) {
@@ -67,6 +106,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
files: [],
linked: false,
agency,
clientScope: agency ? 'full' : 'app_generated',
warning:
'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();
let folderId = link.folderId;
let appRootIds: string[] | undefined;
if (requested && requested !== link.folderId) {
const allowed = await isUnderDriveFolder(requested, link.folderId);
@@ -102,13 +144,32 @@ export async function GET(req: NextRequest) {
{ 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;
}
try {
const currentMeta = await getDriveFileMeta(folderId);
const files = await listFilesInFolder(folderId);
const mapped = files
let mapped = files
.map((f) => ({
id: f.id,
name: f.name,
@@ -124,19 +185,34 @@ export async function GET(req: NextRequest) {
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({
linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,
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,
warning: scopeWarning,
});
} catch (e) {
return NextResponse.json({
linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,

View File

@@ -15,6 +15,10 @@ import {
sanitizeFolder,
serviceSupabase,
} from '@/lib/assets';
import {
filterPortalAssetsForClient,
isAppGeneratedPortalFolder,
} from '@/lib/assets-client-scope';
import {
ensureChildFolder,
getTenantDriveFolderId,
@@ -64,12 +68,18 @@ export async function GET() {
const msg = await r.text();
return NextResponse.json({
assets: [],
agency: true,
clientScope: 'full',
warning: msg.includes('does not exist')
? 'Aplicar sql/asset_files.sql'
: 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();
@@ -81,19 +91,27 @@ export async function GET() {
if (error) {
return NextResponse.json({
assets: [],
agency: false,
clientScope: 'app_generated',
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 */
export async function POST(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { user, ctx } = g as Awaited<ReturnType<typeof gate>> & {
const { user, ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
user: { id: string };
ctx: { tenantId: string };
agency: boolean;
};
const bucketOk = await ensureAssetsBucket();
@@ -115,6 +133,15 @@ export async function POST(req: NextRequest) {
const form = await req.formData();
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);
if (!files.length) {
@@ -234,10 +261,18 @@ export async function POST(req: NextRequest) {
export async function DELETE(req: NextRequest) {
const g = await gate();
if ('error' in g && g.error) return g.error;
const { ctx } = g as Awaited<ReturnType<typeof gate>> & {
const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
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();
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });

View 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,
});
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 });
}

View 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 }
);
}
}

View 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),
});
}

View 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),
});
}

View 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/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,
});
}

View 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 || [],
});
}

View 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),
});
}

View 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 });
}

Some files were not shown because too many files have changed in this diff Show More