Compare commits

139 Commits

Author SHA1 Message Date
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
221 changed files with 56540 additions and 3079 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,7 +17,8 @@
"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",

Binary file not shown.

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,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,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,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,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,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: 'talento-y-deporte',
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"},
},
"talento-y-deporte": {
"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,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,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

@@ -48,7 +48,7 @@ async function main() {
if (!dir || !existsSync(dir)) {
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
}
const status = arg('status', 'revision_interna')!;
const status = arg('status', 'revision_agencia')!;
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));

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

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

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

View File

@@ -1,9 +1,7 @@
-- Creative pipeline: producción interna de creatividades (Blue Parrot y resto
-- de clientes) separada de `creatives` (que es el repositorio de anuncios
-- Meta/Google ya aprobados). Aquí vive el flujo editorial completo:
-- Propuesta -> En diseño -> Revisión interna -> Aprobado / Rechazado -> Publicado
-- Visible también para el cliente en /dashboard/panel-cliente (RLS por tenant).
-- Idempotente.
-- 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(),
@@ -19,10 +17,10 @@ create table if not exists public.creative_pipeline (
-- mes/calendario al que pertenece la pieza (agrupación en el board)
month text, -- 'YYYY-MM'
status text not null default 'propuesta'
status text not null default 'borrador'
check (status in (
'propuesta', 'en_diseno', 'revision_interna',
'aprobado', 'rechazado', 'publicado'
'borrador', 'revision_agencia', 'pendiente_aprobacion',
'aprobada', 'rechazada', 'publicada'
)),
-- portada (todas las piezas tienen una)
@@ -48,6 +46,15 @@ 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);

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

@@ -92,7 +92,7 @@ export async function GET(req: NextRequest) {
}
daily = d.data || [];
} else {
let q = await supabase
let q: any = await supabase
.from('ad_daily')
.select(selectWithLc)
.gte('date', since)
@@ -183,7 +183,7 @@ export async function GET(req: NextRequest) {
dailyRows: daily,
since,
until,
limit: 24,
limit: 80,
});
googleAds = google.ads.length
? google.ads

View File

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

@@ -4,6 +4,8 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
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,
@@ -67,7 +69,27 @@ export async function POST(req: NextRequest) {
try {
if (want('crm') || want('metrics')) {
out.crm = await syncAllClientifyTenants({ tenantId });
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 = {

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 (p. ej. Pipeline-creatividades). 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,319 @@
/**
* 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 { 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
}
}
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 }
);
}
return NextResponse.json({
ok: true,
brandbook: book,
completeness: brandbookCompleteness(book),
});
}

View File

@@ -0,0 +1,401 @@
/**
* 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 { 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>
);
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),
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,230 @@
/**
* POST multipart: file (.txt/.md/.csv) 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';
export const dynamic = 'force-dynamic';
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 lower = filename.toLowerCase();
if (
!lower.endsWith('.txt') &&
!lower.endsWith('.md') &&
!lower.endsWith('.csv') &&
!lower.endsWith('.json')
) {
return NextResponse.json(
{
error:
'Por ahora sube .txt, .md, .csv o .json. PDF/DOCX: pega el texto en el campo.',
},
{ status: 400 }
);
}
text = await f.text();
}
} 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 });
}
}
return NextResponse.json({
ok: true,
brandbook: next,
completeness: brandbookCompleteness(next),
ingested: filename,
});
}

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

View File

@@ -0,0 +1,256 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import type { RrssPiece } from '@/components/RrssMonthView';
import {
asPlanObject,
emptyMonthPlan,
newPieceId,
piecesFromDocs,
type CalendarDoc,
} from '@/lib/calendar-documents';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function str(v: unknown, max = 4000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
if (!t) return null;
return t.slice(0, max);
}
async function loadDocs(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string
): Promise<CalendarDoc[]> {
const r = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
return r.ok ? ((await r.json()) as CalendarDoc[]) : [];
}
/** Agency: list RRSS calendar pieces for the active client. */
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ pieces: [], warning: 'Selecciona un cliente en Contexto.' },
{ status: 200 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const docs = await loadDocs(base, headers, ctx.tenantId);
const pieces = piecesFromDocs(docs);
// Mark which are already linked in pipeline
const pipe = await fetch(
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&calendar_piece_id=not.is.null&select=id,calendar_piece_id`,
{ headers, cache: 'no-store' }
);
const links = pipe.ok
? ((await pipe.json()) as { id: string; calendar_piece_id: string }[])
: [];
const linked = new Set(links.map((l) => l.calendar_piece_id));
return NextResponse.json({
pieces: pieces.map((p) => ({
...p,
linked_pipeline: linked.has(p.id),
pipeline_id: links.find((l) => l.calendar_piece_id === p.id)?.id || null,
})),
});
}
/**
* Agency upsert of a calendar piece.
* Body: { pieceId?, date, title, format?, hook?, body?, cta?, hashtags?, pillar?, time?, platform?, kind? }
* - with pieceId: update existing
* - without: create in that month's document (create month plan if missing)
*/
export async function PATCH(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const pieceId = str(body.pieceId, 120);
const title = str(body.title, 300);
const date = str(body.date, 10);
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'date (YYYY-MM-DD) requerida' }, { status: 400 });
}
const ym = date.slice(0, 7);
const docs = await loadDocs(base, headers, ctx.tenantId);
let target = docs.find((d) => {
const plan = asPlanObject(d.content);
return plan && String(plan.month) === ym;
});
let plan = target ? asPlanObject(target.content) : null;
if (!plan) {
plan = emptyMonthPlan(ym, ctx.label) as unknown as Record<string, unknown>;
}
const pieces = Array.isArray(plan.pieces)
? ([...plan.pieces] as RrssPiece[])
: [];
const patch: Partial<RrssPiece> = {
title,
date,
time: str(body.time, 8) || undefined,
platform: str(body.platform, 40) || 'instagram',
format: str(body.format, 40) || undefined,
pillar: str(body.pillar, 80) || undefined,
hook: str(body.hook, 500) || undefined,
body: str(body.body, 8000) || undefined,
cta: str(body.cta, 300) || undefined,
hashtags: str(body.hashtags, 500) || undefined,
kind: str(body.kind, 20) || undefined,
status: str(body.status, 40) || 'planned',
};
let saved: RrssPiece;
if (pieceId) {
const idx = pieces.findIndex((p) => p.id === pieceId);
if (idx < 0) {
// Piece moved month or missing — create with same id in target month
saved = { id: pieceId, title, date, platform: 'instagram', ...patch } as RrssPiece;
// Remove from any other month docs
for (const d of docs) {
const p = asPlanObject(d.content);
if (!p || !Array.isArray(p.pieces)) continue;
const list = p.pieces as RrssPiece[];
const i = list.findIndex((x) => x.id === pieceId);
if (i < 0) continue;
if (d.id === target?.id) continue;
list.splice(i, 1);
p.pieces = list;
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ content: p }),
});
}
pieces.push(saved);
} else {
saved = { ...pieces[idx], ...patch, id: pieceId, title, date };
pieces[idx] = saved;
}
} else {
saved = {
id: newPieceId(date, title),
platform: 'instagram',
status: 'planned',
...patch,
title,
date,
} as RrssPiece;
pieces.push(saved);
}
plan.pieces = pieces.sort((a, b) =>
String(a.date).localeCompare(String(b.date))
);
plan.month = ym;
if (target) {
const r = await fetch(`${base}/rest/v1/documents?id=eq.${target.id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ content: plan }),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
} else {
const row = {
tenant_id: ctx.tenantId,
type: 'calendar',
title: `RRSS ${(plan as { label?: string }).label || ym}`,
content: plan,
status: 'approved',
};
const r = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const created = await r.json();
target = Array.isArray(created) ? created[0] : created;
}
return NextResponse.json({
ok: true,
piece: {
...saved,
month: ym,
document_id: target?.id,
},
});
}

View File

@@ -0,0 +1,153 @@
/**
* POST { month: 'YYYY-MM' } → editorial preview for active tenant.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { asPlanObject } from '@/lib/calendar-documents';
import { generateMonthPreview } from '@/lib/calendar-generate';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
import {
BRANDBOOK_DOC_TYPE,
parseBrandbook,
} from '@/lib/brandbook';
import {
pipelinePublishDay,
type PipelineCalendarRow,
} from '@/lib/pipeline-calendar';
import type { PublishedNetworkPost } from '@/lib/calendar-strategy';
import { normalizeApprovalStatus } from '@/lib/approval-status';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as {
month?: string;
avoid_titles?: string[];
gen_options?: Record<string, unknown>;
options?: Record<string, unknown>;
};
const month = String(body.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
}
const avoidTitles = Array.isArray(body.avoid_titles)
? body.avoid_titles
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
.map((t) => t.trim().slice(0, 160))
.slice(0, 40)
: [];
const genOptions = (body.gen_options || body.options || null) as
| Record<string, unknown>
| null;
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
const docs = await restGet<{ content?: unknown }[]>(
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=content&order=created_at.desc&limit=48`
);
const history: RrssMonthPlan[] = [];
for (const d of docs.data || []) {
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
if (plan?.month && plan.month !== month && Array.isArray(plan.pieces)) {
history.push(plan as unknown as RrssMonthPlan);
}
}
// Prefer same calendar month from prior years first (e.g. 2025-11 before 2026-10)
const mm = month.slice(5, 7);
history.sort((a, b) => {
const aSame = a.month.slice(5, 7) === mm ? 0 : 1;
const bSame = b.month.slice(5, 7) === mm ? 0 : 1;
if (aSame !== bSame) return aSame - bSame;
return String(b.month).localeCompare(String(a.month));
});
const bb = await restGet<{ content?: unknown }[]>(
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
);
const brandbook = parseBrandbook(bb.data?.[0]?.content);
// Publicaciones reales en redes (pipeline): publicadas o con rastro IG
const pipe = await restGet<PipelineCalendarRow[]>(
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=id,title,format,month,status,caption,notes,created_at,scheduled_at,channels,publish_status,external_ref,published_at&order=created_at.desc&limit=400`
);
const publishedPosts: PublishedNetworkPost[] = [];
for (const row of pipe.data || []) {
const st = normalizeApprovalStatus(row.status);
const ref = String(row.external_ref || '').toLowerCase();
// Solo piezas ya salidas / sync IG (no borradores del pipeline)
if (
st !== 'publicada' &&
row.publish_status !== 'published' &&
!ref.startsWith('ig-')
) {
continue;
}
const day =
pipelinePublishDay(row) ||
row.published_at?.slice(0, 10) ||
row.created_at?.slice(0, 10) ||
null;
publishedPosts.push({
title: row.title,
caption: row.caption,
format: row.format,
day,
month: row.month || (day ? day.slice(0, 7) : null),
status: row.status,
channels: row.channels,
});
}
const result = await generateMonthPreview({
ym: month,
slug,
label: ctx.label,
history,
brandbook,
publishedPosts,
avoidTitles,
genOptions: genOptions || undefined,
});
return NextResponse.json({
ok: true,
plan: result.plan,
rationale: result.rationale,
source: result.source,
brand: slug,
brandbook: !!brandbook,
history_months: history.length,
network_posts: publishedPosts.length,
});
}

View File

@@ -30,6 +30,20 @@ export async function POST() {
);
}
const t = await restGet<{ slug?: string; name?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug,name`
);
const slug = t.data?.[0]?.slug || '';
if (slug !== 'agua-miia') {
return NextResponse.json(
{
error:
'Este botón solo carga el plan de octubre de Agua Miia en su propio portal. No publica en Instagram ni copia el calendario a otros clientes.',
},
{ status: 400 }
);
}
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'no service role' }, { status: 500 });

View File

@@ -0,0 +1,55 @@
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 {
buildOfficialCsv,
buildOfficialXlsx,
} from '@/lib/calendar-template';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
/**
* GET /api/calendar/template?format=xlsx|csv
* Official blank multi-client template (NOT a filled BPS calendar).
*/
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);
const label =
ctx.tenantId && !ctx.portfolioWide ? ctx.label || 'CLIENTE' : 'CLIENTE';
const format = (req.nextUrl.searchParams.get('format') || 'xlsx').toLowerCase();
if (format === 'csv') {
const csv = buildOfficialCsv(label);
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="plantilla-calendario-4ventures.csv"',
'Cache-Control': 'no-store',
},
});
}
const { buffer, filename } = await buildOfficialXlsx(label);
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -0,0 +1,211 @@
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 {
parseJsonCalendar,
sheetsToPlans,
type ImportResult,
type SheetRows,
} from '@/lib/calendar-import';
import { buildOfficialCsv } from '@/lib/calendar-template';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const MAX_BYTES = 8 * 1024 * 1024;
/** @deprecated Prefer GET /api/calendar/template?format=csv */
export async function GET() {
return new NextResponse(buildOfficialCsv('CLIENTE'), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition':
'attachment; filename="plantilla-calendario-4ventures.csv"',
'Cache-Control': 'no-store',
},
});
}
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 el equipo de 4Ventures puede subir calendarios.' },
{ status: 403 }
);
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const form = await req.formData().catch(() => null);
const file = form?.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Adjunta un CSV, Excel o JSON.' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'El archivo supera 8 MB.' }, { status: 400 });
}
const name = file.name || 'calendario';
const buf = Buffer.from(await file.arrayBuffer());
const text = buf.toString('utf8').replace(/^\uFEFF/, '');
const yearHint = new Date().getFullYear();
let parsed: ImportResult;
const lower = name.toLowerCase();
if (lower.endsWith('.json') || text.trim().startsWith('{') || text.trim().startsWith('[')) {
const json = parseJsonCalendar(text, ctx.label);
if (!json || !json.plans.length) {
return NextResponse.json(
{ error: json?.warnings[0] || 'JSON no válido.' },
{ status: 400 }
);
}
parsed = json;
} else {
try {
const mod = await import('xlsx');
const XLSX = (mod as { default?: typeof import('xlsx') }).default ?? mod;
const wb = XLSX.read(buf, { type: 'buffer', cellDates: true, raw: false });
const sheets: { name: string; rows: SheetRows }[] = [];
for (const sheetName of wb.SheetNames) {
const ws = wb.Sheets[sheetName];
if (!ws) continue;
const rows = XLSX.utils.sheet_to_json<(string | number | null)[]>(ws, {
header: 1,
defval: '',
raw: false,
blankrows: false,
}) as SheetRows;
sheets.push({ name: sheetName, rows });
}
parsed = sheetsToPlans(sheets, ctx.label, yearHint);
} catch (e) {
return NextResponse.json(
{
error:
e instanceof Error
? `No se pudo leer el Excel/CSV: ${e.message}`
: 'No se pudo leer el Excel/CSV',
},
{ status: 400 }
);
}
}
if (!parsed.plans.length) {
return NextResponse.json(
{
error: parsed.warnings[0] || 'No se leyeron piezas.',
sheets: parsed.sheets,
},
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const saved: { month: string; pieces: number }[] = [];
try {
for (const plan of parsed.plans) {
await upsertPlan(base, headers, ctx.tenantId, plan);
saved.push({ month: plan.month, pieces: plan.pieces.length });
}
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Error al guardar' },
{ status: 500 }
);
}
return NextResponse.json({
ok: true,
tenant: ctx.label,
months: saved,
warnings: parsed.warnings,
sheets: parsed.sheets,
});
}
async function upsertPlan(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string,
plan: RrssMonthPlan
) {
const title = `RRSS ${plan.label}`;
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = existing.ok
? ((await existing.json()) as {
id: string;
title: 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 month = c && typeof c === 'object' ? c.month : null;
if (month === plan.month || d.title === title) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
}
}
const row = {
tenant_id: tenantId,
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(`No se pudo guardar ${plan.month}: ${(await ins.text()).slice(0, 280)}`);
}
}

View File

@@ -0,0 +1,80 @@
/**
* GET /api/change-history — listado del historial de cambios.
* Query: tenant_id?, entity_type?, limit?, offset?
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { homeTenantId, isAgencyRole, userRole } from '@/lib/agency';
import { listChangeHistory } from '@/lib/change-history';
import { resolveActiveTenant } from '@/lib/tenant-context';
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 role = userRole(user);
const isAgency = isAgencyRole(role);
const sp = req.nextUrl.searchParams;
const limit = Number(sp.get('limit') || 50);
const offset = Number(sp.get('offset') || 0);
const entityType = sp.get('entity_type') || sp.get('entityType') || null;
let tenantId =
sp.get('tenant_id') || sp.get('tenantId') || null;
if (!isAgency) {
// Cliente: solo su tenant
const own = homeTenantId(user);
const ctx = await resolveActiveTenant(user);
tenantId = own || ctx.tenantId || null;
if (!tenantId) {
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
}
} else if (!tenantId) {
// Agencia sin filtro: usa contexto activo si hay cliente seleccionado
const ctx = await resolveActiveTenant(user);
if (ctx.tenantId && !ctx.portfolioWide) {
tenantId = ctx.tenantId;
}
// Si portfolio wide, tenantId queda null → historial global
}
const { rows, error } = await listChangeHistory({
tenantId,
entityType,
limit,
offset,
});
if (error) {
const missing =
/relation .*change_history.* does not exist|Could not find the table/i.test(
error
);
return NextResponse.json(
{
ok: false,
error: missing
? 'Falta crear la tabla change_history (sql/change_history.sql).'
: error,
rows: [],
needsMigration: missing,
},
{ status: missing ? 503 : 500 }
);
}
return NextResponse.json({
ok: true,
rows,
tenant_id: tenantId,
portfolio_wide: isAgency && !tenantId,
});
}

View File

@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
const tenantId = ctx.tenantId ||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
// Never fall back to agency home tenant while "vista global" — that mixes clients.
const tenantId = ctx.tenantId;
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
if (!tenantId && !ctx.portfolioWide) {
return NextResponse.json(
@@ -30,7 +30,6 @@ export async function POST(req: NextRequest) {
{ status: 403 }
);
}
// 1. Extraer mensaje del usuario
let userMessage = '';
try {
@@ -92,6 +91,11 @@ export async function POST(req: NextRequest) {
normQuery.includes('instrucciones');
if (isHelpQuery) {
if (agency && ctx.portfolioWide) {
return reply(
'Selecciona un **cliente en Contexto** para usar el asistente con los datos de esa cuenta. En vista global no mezclo información entre clientes.'
);
}
return reply(
`👋 **¡Hola! Soy tu Asistente Financiero & de Crecimiento de 4Ventures** para **${companyName}**.\n\n` +
`Estoy conectado directamente a tu base de datos analítica y ejecuto cálculos matemáticos deterministas (temperatura 0.0, sin alucinaciones). Aquí tienes todo lo que puedes preguntarme:\n\n` +
@@ -121,6 +125,12 @@ export async function POST(req: NextRequest) {
);
}
if (ctx.portfolioWide || !tenantId) {
return reply(
'Selecciona un **cliente en Contexto** para consultar sus datos (calendario, redes, KPIs). En vista global no mezclo información entre cuentas.'
);
}
// 4. Intención: Simulación Determinista "¿Qué pasa si...?"
if (
normQuery.includes('simul') ||
@@ -226,7 +236,10 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
method: 'POST',
headers,
body: JSON.stringify({ p_month: currentMonth })
body: JSON.stringify({
p_month: currentMonth,
p_tenant_id: tenantId,
}),
});
if (res.ok) {

View File

@@ -0,0 +1,264 @@
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 { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import {
BRANDBOOK_DOC_TYPE,
brandbookToPrompt,
parseBrandbook,
} from '@/lib/brandbook';
import {
buildRrssGenerationBrief,
generationRequestNoteMarker,
isRrssPipelineFormat,
type RrssPipelineFormat,
} from '@/lib/rrss-generation';
import { createClient as createAdminClient } from '@supabase/supabase-js';
export const dynamic = 'force-dynamic';
function admin() {
return createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
/**
* POST — create pipeline piece + generation_request (channel=rrss) in one shot.
* Body: { title, format, month?, caption?, notes?, pillar?, calendar_piece_id?,
* calendar_piece?, scheduled_at?, channels?, variants_requested? }
*/
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 body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
}
const title = String(body.title || '').trim();
const format = body.format as RrssPipelineFormat;
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
if (!isRrssPipelineFormat(format)) {
return NextResponse.json(
{ error: 'format debe ser post|story|reel|carrusel' },
{ status: 400 }
);
}
const db = admin();
const tenantId = ctx.tenantId;
const { data: tenant } = await db
.from('tenants')
.select('id, slug, name')
.eq('id', tenantId)
.maybeSingle();
// Recent pipeline for continuity
const { data: recentRows } = await db
.from('creative_pipeline')
.select('title, format, caption')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(12);
// Brandbook
let brandbookPrompt: string | null = null;
const { data: brandDoc } = await db
.from('documents')
.select('content')
.eq('tenant_id', tenantId)
.eq('type', BRANDBOOK_DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (brandDoc?.content) {
try {
const raw =
typeof brandDoc.content === 'string'
? JSON.parse(brandDoc.content)
: brandDoc.content;
const book = parseBrandbook(raw);
if (book) brandbookPrompt = brandbookToPrompt(book);
} catch {
/* ignore */
}
}
// Social cache (optional)
let socialHistory: unknown = null;
const { data: integs } = await db
.from('integrations')
.select('provider, meta')
.eq('tenant_id', tenantId)
.in('provider', ['instagram', 'facebook']);
if (integs?.length) {
socialHistory = integs.map((i) => ({
provider: i.provider,
cache: (i.meta as { social_cache?: unknown } | null)?.social_cache || null,
}));
}
const calendarPieceId =
typeof body.calendar_piece_id === 'string' ? body.calendar_piece_id : null;
const calendarPiece =
body.calendar_piece && typeof body.calendar_piece === 'object'
? (body.calendar_piece as Record<string, unknown>)
: null;
const caption =
typeof body.caption === 'string' ? body.caption.trim() || null : null;
const pillar =
typeof body.pillar === 'string' ? body.pillar.trim() || null : null;
const month =
typeof body.month === 'string'
? body.month
: typeof calendarPiece?.date === 'string'
? String(calendarPiece.date).slice(0, 7)
: null;
const notesBase =
typeof body.notes === 'string' ? body.notes.trim() : pillar ? `Pilar: ${pillar}` : '';
const { data: item, error: insErr } = await db
.from('creative_pipeline')
.insert({
tenant_id: tenantId,
title,
format,
month,
status: 'borrador',
caption,
notes: notesBase || null,
calendar_piece_id: calendarPieceId,
scheduled_at:
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
channels: Array.isArray(body.channels)
? body.channels
: ['instagram', 'facebook'],
created_by: user.id,
})
.select('*')
.single();
if (insErr || !item) {
return NextResponse.json(
{ error: insErr?.message || 'No se pudo crear la pieza' },
{ status: 500 }
);
}
const brief = buildRrssGenerationBrief({
tenantId,
clientSlug: tenant?.slug || 'cliente',
clientName: tenant?.name || ctx.label || 'Cliente',
format,
pipelineId: item.id,
calendarPieceId,
title,
caption,
notes: notesBase,
pillar,
month,
scheduledAt:
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
calendarPiece: calendarPiece
? {
id: calendarPieceId,
title: String(calendarPiece.title || title),
date: calendarPiece.date ? String(calendarPiece.date) : null,
time: calendarPiece.time ? String(calendarPiece.time) : null,
format: calendarPiece.format ? String(calendarPiece.format) : format,
pillar: calendarPiece.pillar
? String(calendarPiece.pillar)
: pillar,
hook: calendarPiece.hook ? String(calendarPiece.hook) : null,
body: calendarPiece.body
? String(calendarPiece.body)
: caption,
cta: calendarPiece.cta ? String(calendarPiece.cta) : null,
hashtags: calendarPiece.hashtags
? String(calendarPiece.hashtags)
: null,
platform: calendarPiece.platform
? String(calendarPiece.platform)
: 'instagram',
}
: null,
recentPipeline: (recentRows || []).map((r) => ({
title: r.title,
format: r.format,
caption: r.caption,
})),
brandbookPrompt,
socialHistory,
variantsRequested: Number(body.variants_requested || 2),
});
const { data: gr, error: grErr } = await db
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: 'calendario',
source_creative_id: null,
brief,
status: 'pendiente',
})
.select('*')
.single();
if (grErr || !gr) {
return NextResponse.json(
{
error: grErr?.message || 'Pieza creada pero falló la cola Claude',
item,
},
{ status: 502 }
);
}
const marker = generationRequestNoteMarker(gr.id);
const notesWithMarker = [notesBase, marker, 'Auto: Claude + Freepik + Canva']
.filter(Boolean)
.join('\n');
await db
.from('creative_pipeline')
.update({ notes: notesWithMarker, updated_at: new Date().toISOString() })
.eq('id', item.id)
.eq('tenant_id', tenantId);
dispatchN8nWebhookAsync('generation_request.created', {
generation_request: { ...gr, client_id: tenantId },
});
return NextResponse.json({
ok: true,
item: { ...item, notes: notesWithMarker },
generation_request: gr,
message:
'Pieza en cola. n8n/Claude generará cover+caption y escribirá en el pipeline.',
});
}

View File

@@ -0,0 +1,329 @@
/**
* Agency Meta actions for a pipeline item.
*
* POST { id, action: 'publish_now' | 'delete_meta' | 'update_live', caption? }
*/
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 {
deleteMetaPosts,
mergePermalinkNotes,
publishPipelineItem,
updateLivePipelineItem,
type PipelinePublishRow,
} from '@/lib/meta-publish';
import { normalizeChannels } from '@/lib/pipeline-schedule';
import { sanitizePublishCaption } from '@/lib/publish-caption';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
const SELECT =
'id,tenant_id,title,format,caption,cover_path,cover_mime,slide_paths,channels,status,notes,publish_status,publish_attempts,meta_ig_media_id,meta_fb_post_id,scheduled_at';
async function loadItem(
id: string,
tenantId: string,
headers: Record<string, string>
): Promise<(PipelinePublishRow & { notes?: string | null; status?: string }) | null> {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as (PipelinePublishRow & {
notes?: string | null;
status?: string;
})[];
return rows[0] || null;
}
async function patchItem(
id: string,
headers: Record<string, string>,
body: Record<string, unknown>
) {
return fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({ ...body, updated_at: new Date().toISOString() }),
}
);
}
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();
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const body = (await req.json().catch(() => ({}))) as {
id?: string;
action?: string;
caption?: string;
};
const id = typeof body.id === 'string' ? body.id.trim() : '';
const action =
body.action === 'delete_meta'
? 'delete_meta'
: body.action === 'update_live'
? 'update_live'
: 'publish_now';
if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
}
let item = await loadItem(id, ctx.tenantId, headers);
if (!item) {
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
}
// Optional caption override before update/publish (strip production notes)
if (typeof body.caption === 'string') {
const scrub = sanitizePublishCaption(body.caption, { maxLen: 4000 });
const caption = scrub.caption || null;
await patchItem(id, headers, { caption });
item = { ...item, caption };
} else if (item.caption) {
const scrub = sanitizePublishCaption(item.caption, { maxLen: 4000 });
if (scrub.changed) {
await patchItem(id, headers, { caption: scrub.caption || null });
item = { ...item, caption: scrub.caption || null };
}
}
if (action === 'delete_meta') {
const del = await deleteMetaPosts(item);
const patchBody: Record<string, unknown> = {
publish_error: del.ok
? null
: del.error || 'Borrado parcial en Meta',
};
if (del.clearIg) patchBody.meta_ig_media_id = null;
if (del.clearFb) patchBody.meta_fb_post_id = null;
if (del.clearIg && del.clearFb) {
patchBody.publish_status = 'idle';
patchBody.published_at = null;
} else if (del.partialOk) {
// Still live on one network
patchBody.publish_status = 'published';
}
const patch = await patchItem(id, headers, patchBody);
const rows = patch.ok ? await patch.json() : null;
if (!del.ok && !del.partialOk) {
return NextResponse.json(
{
error: del.error || 'No se pudo borrar en Meta',
item: Array.isArray(rows) ? rows[0] : null,
igDeleted: del.igDeleted,
fbDeleted: del.fbDeleted,
},
{ status: 502 }
);
}
return NextResponse.json({
ok: del.ok,
partial: !del.ok && del.partialOk,
action: 'delete_meta',
igDeleted: del.igDeleted,
fbDeleted: del.fbDeleted,
warning: del.ok ? null : del.error,
item: Array.isArray(rows) ? rows[0] : null,
});
}
if (action === 'update_live') {
if (!item.cover_path) {
return NextResponse.json(
{ error: 'Sube una portada antes de actualizar en redes' },
{ status: 400 }
);
}
if (!item.meta_ig_media_id && !item.meta_fb_post_id) {
return NextResponse.json(
{ error: 'Esta pieza no está publicada en redes todavía' },
{ status: 400 }
);
}
await patchItem(id, headers, {
publish_status: 'publishing',
publish_error: null,
});
const result = await updateLivePipelineItem(item);
if (!result.ok) {
const fail = await patchItem(id, headers, {
publish_status: 'failed',
publish_error: result.error || 'Error al actualizar en redes',
meta_ig_media_id: result.igMediaId ?? item.meta_ig_media_id,
meta_fb_post_id: result.fbPostId ?? item.meta_fb_post_id,
});
const rows = fail.ok ? await fail.json() : null;
return NextResponse.json(
{
error: result.error || 'Error al actualizar en redes',
detail: result.detail,
item: Array.isArray(rows) ? rows[0] : null,
},
{ status: 502 }
);
}
const notes = mergePermalinkNotes(
(item as { notes?: string | null }).notes,
result.igPermalink,
null
);
const okPatch = await patchItem(id, headers, {
publish_status: 'published',
publish_error: null,
published_at: new Date().toISOString(),
status: 'publicada',
meta_ig_media_id: result.igMediaId || null,
meta_fb_post_id: result.fbPostId || null,
caption: item.caption || null,
notes,
});
const rows = okPatch.ok ? await okPatch.json() : null;
return NextResponse.json({
ok: true,
action: 'update_live',
detail: result.detail,
igMediaId: result.igMediaId,
fbPostId: result.fbPostId,
item: Array.isArray(rows) ? rows[0] : null,
});
}
// publish_now
if (!item.cover_path) {
return NextResponse.json(
{ error: 'Sube una portada antes de publicar' },
{ status: 400 }
);
}
const attempts = (item.publish_attempts || 0) + 1;
await patchItem(id, headers, {
publish_status: 'publishing',
publish_attempts: attempts,
publish_error: null,
scheduled_at: new Date().toISOString(),
status:
item.status === 'aprobada' ||
item.status === 'publicada' ||
item.status === 'aprobado' ||
item.status === 'publicado'
? item.status === 'publicado' || item.status === 'publicada'
? 'publicada'
: 'aprobada'
: 'aprobada',
});
// Publish IG then FB, persisting each id immediately (avoids IG duplicates on retry).
const channels = normalizeChannels(item.channels);
const errors: string[] = [];
let igMediaId: string | null = item.meta_ig_media_id || null;
let fbPostId: string | null = item.meta_fb_post_id || null;
let igPermalink: string | null = null;
let fbPermalink: string | null = null;
if (channels.includes('instagram') && !igMediaId) {
const ig = await publishPipelineItem(item, { skipFb: true });
if (ig.igMediaId) {
igMediaId = ig.igMediaId;
igPermalink = ig.igPermalink || null;
await patchItem(id, headers, { meta_ig_media_id: igMediaId });
item = { ...item, meta_ig_media_id: igMediaId };
}
if (!ig.ok) errors.push(ig.error || 'IG falló');
}
if (channels.includes('facebook') && !fbPostId && errors.length === 0) {
const fb = await publishPipelineItem(item, { skipIg: true });
if (fb.fbPostId) {
fbPostId = fb.fbPostId;
fbPermalink = fb.fbPermalink || null;
await patchItem(id, headers, { meta_fb_post_id: fbPostId });
item = { ...item, meta_fb_post_id: fbPostId };
}
if (!fb.ok) errors.push(fb.error || 'FB falló');
}
const wantIg = channels.includes('instagram');
const wantFb = channels.includes('facebook');
const ok =
(!wantIg || Boolean(igMediaId)) &&
(!wantFb || Boolean(fbPostId)) &&
errors.length === 0;
if (!ok) {
const fail = await patchItem(id, headers, {
publish_status: 'failed',
publish_error: errors.join(' · ') || 'Error al publicar',
meta_ig_media_id: igMediaId,
meta_fb_post_id: fbPostId,
});
const rows = fail.ok ? await fail.json() : null;
return NextResponse.json(
{
error: errors.join(' · ') || 'Error al publicar',
item: Array.isArray(rows) ? rows[0] : null,
},
{ status: 502 }
);
}
const notes = mergePermalinkNotes(
(item as { notes?: string | null }).notes,
igPermalink,
fbPermalink
);
const okPatch = await patchItem(id, headers, {
publish_status: 'published',
publish_error: null,
published_at: new Date().toISOString(),
status: 'publicada',
meta_ig_media_id: igMediaId,
meta_fb_post_id: fbPostId,
notes,
});
const rows = okPatch.ok ? await okPatch.json() : null;
return NextResponse.json({
ok: true,
action: 'publish_now',
igMediaId,
fbPostId,
igPermalink,
item: Array.isArray(rows) ? rows[0] : null,
});
}

View File

@@ -7,30 +7,36 @@ import {
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
normalizeChannels,
parseScheduledAt,
PUBLISH_STATUSES,
} from '@/lib/pipeline-schedule';
import {
asPlanObject,
buildCaption,
mapFormatToPipeline,
piecesFromDocs,
scheduledAtFromPiece,
type CalendarDoc,
} from '@/lib/calendar-documents';
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
import { sanitizePublishCaption } from '@/lib/publish-caption';
import {
CLIENT_ALLOWED_TRANSITIONS,
CLIENT_VISIBLE_STATUSES,
canSendToClient,
normalizeApprovalStatus,
parseSharedStatusWrite,
type SharedApprovalStatus,
} from '@/lib/approval-status';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
const SELECT =
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
const ALL_STATUS = [
'propuesta',
'en_diseno',
'revision_interna',
'aprobado',
'rechazado',
'publicado',
] as const;
type Status = (typeof ALL_STATUS)[number];
const STATUS_SET = new Set<string>(ALL_STATUS);
// El cliente solo puede mover una pieza que esté en revisión interna,
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
// "publicado") son cosa del equipo de la agencia.
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
revision_interna: ['aprobado', 'rechazado'],
};
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
@@ -65,7 +71,7 @@ export async function GET(req: NextRequest) {
let q = `${supabaseUrl()}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=${SELECT}&order=month.asc,created_at.asc`;
)}&select=${SELECT}&order=month.asc,created_at.asc&limit=1000`;
if (month) q += `&month=eq.${encodeURIComponent(month)}`;
if (ctx.viaView) {
@@ -80,21 +86,73 @@ export async function GET(req: NextRequest) {
if (!r.ok) {
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
}
return NextResponse.json({ items: await r.json(), isAgency });
return NextResponse.json({
items: redactScheduleForClient(
filterPipelineForRole(await r.json(), isAgency),
isAgency
),
isAgency,
});
}
let query = supabase
.from('creative_pipeline')
.select(SELECT)
.eq('tenant_id', ctx.tenantId)
.order('month', { ascending: true })
.order('created_at', { ascending: true });
.order('created_at', { ascending: true })
.limit(1000);
if (month) query = query.eq('month', month);
const { data, error } = await query;
if (error) {
return NextResponse.json({ items: [], isAgency, warning: error.message });
}
return NextResponse.json({ items: data ?? [], isAgency });
return NextResponse.json({
items: redactScheduleForClient(
filterPipelineForRole(data ?? [], isAgency),
isAgency
),
isAgency,
});
}
function filterPipelineForRole<T extends { status?: string }>(
rows: T[],
isAgency: boolean
): T[] {
if (isAgency) {
return rows.map((row) => ({
...row,
status: normalizeApprovalStatus(row.status),
}));
}
const visible = new Set(CLIENT_VISIBLE_STATUSES);
return rows
.map((row) => ({
...row,
status: normalizeApprovalStatus(row.status),
}))
.filter((row) => visible.has(row.status as (typeof CLIENT_VISIBLE_STATUSES)[number]));
}
function redactScheduleForClient<T extends Record<string, unknown>>(
rows: T[],
isAgency: boolean
): T[] {
if (isAgency) return rows;
return rows.map((row) => {
const {
publish_error: _e,
publish_attempts: _a,
meta_ig_media_id: _i,
meta_fb_post_id: _f,
publish_status: _p,
...rest
} = row as T & Record<string, unknown>;
// Clients may see scheduled day on calendar later; hide worker internals.
return rest as T;
});
}
export async function POST(req: NextRequest) {
@@ -122,29 +180,86 @@ export async function POST(req: NextRequest) {
);
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const title = str(body.title, 300);
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const format = str(body.format, 20);
const row = {
tenant_id: ctx.tenantId,
title,
external_ref: str(body.external_ref, 80),
format: format && FORMATS.has(format) ? format : 'post',
month: str(body.month, 7),
caption: str(body.caption, 4000),
notes: str(body.notes, 2000),
status: 'propuesta',
created_by: user.id,
};
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const calendarPieceId = str(body.calendar_piece_id, 120);
let title = str(body.title, 300);
let format = str(body.format, 20);
let month = str(body.month, 7);
let caption =
sanitizePublishCaption(str(body.caption, 4000) || '', { maxLen: 4000 })
.caption || null;
let notes = str(body.notes, 2000);
let scheduledAt = parseScheduledAt(body.scheduled_at);
let channels = 'channels' in body ? normalizeChannels(body.channels) : null;
const resolvedCalendarId = calendarPieceId;
// Inherit from RRSS calendar piece when linked.
if (calendarPieceId) {
const docsRes = await fetch(
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : [];
const cal = piecesFromDocs(docs).find((p) => p.id === calendarPieceId);
if (cal) {
title = title || cal.title;
format = format || mapFormatToPipeline(cal.format, cal.kind);
month = month || cal.month;
caption =
sanitizePublishCaption(caption || buildCaption(cal) || '', {
maxLen: 4000,
}).caption || null;
if (!notes) {
const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform]
.filter(Boolean)
.join(' · ');
notes = bits || null;
}
if (scheduledAt === undefined || scheduledAt === null) {
scheduledAt = scheduledAtFromPiece(cal);
}
if (!channels) {
const plat = (cal.platform || '').toLowerCase();
channels =
plat.includes('linkedin')
? ['instagram']
: plat.includes('facebook')
? ['facebook', 'instagram']
: ['instagram', 'facebook'];
}
}
}
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const row: Record<string, unknown> = {
tenant_id: ctx.tenantId,
title,
external_ref: str(body.external_ref, 80),
format: format && FORMATS.has(format) ? format : 'post',
month: month || null,
caption: caption || null,
notes: notes || null,
status: 'borrador',
created_by: user.id,
calendar_piece_id: resolvedCalendarId || null,
};
if (scheduledAt) {
row.scheduled_at = scheduledAt;
row.publish_status = 'idle';
}
if (channels) row.channels = channels;
const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, {
method: 'POST',
headers: {
@@ -158,7 +273,39 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 });
const item = Array.isArray(data) ? data[0] : data;
// Bidirectional: stamp calendar piece with pipeline_id for later merges.
if (resolvedCalendarId && item?.id && headers) {
const docsRes = await fetch(
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : [];
for (const doc of docs) {
const plan = asPlanObject(doc.content);
if (!plan || !Array.isArray(plan.pieces)) continue;
const pieces = plan.pieces as { id: string; pipeline_id?: string }[];
const idx = pieces.findIndex((p) => p.id === resolvedCalendarId);
if (idx < 0) continue;
pieces[idx] = { ...pieces[idx], pipeline_id: item.id };
plan.pieces = pieces;
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: plan }),
});
break;
}
}
return NextResponse.json({ item }, { status: 201 });
}
export async function PATCH(req: NextRequest) {
@@ -188,7 +335,7 @@ export async function PATCH(req: NextRequest) {
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status,scheduled_at`,
{ headers: headers || undefined, cache: 'no-store' }
);
const curRows = curRes.ok ? await curRes.json() : [];
@@ -201,24 +348,77 @@ export async function PATCH(req: NextRequest) {
updated_at: new Date().toISOString(),
};
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
const next = body.status as Status;
const currentNorm = normalizeApprovalStatus(current);
const parsedNext =
'status' in body ? parseSharedStatusWrite(body.status) : null;
if (parsedNext) {
const next: SharedApprovalStatus = parsedNext;
if (agency) {
clean.status = next;
} else {
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
const allowed = CLIENT_ALLOWED_TRANSITIONS[currentNorm] || [];
if (!allowed.includes(next)) {
return NextResponse.json(
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
{
error:
'Como cliente solo puedes aprobar o rechazar una pieza pendiente de tu validación',
},
{ status: 403 }
);
}
clean.status = next;
}
if (next === 'aprobado') {
if (next === 'aprobada') {
clean.approved_by = user.id;
clean.approved_at = new Date().toISOString();
if (agency && curRows?.[0]?.scheduled_at) {
clean.publish_status = 'queued';
}
if (!agency) {
dispatchN8nWebhookAsync('pipeline.approved', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
}
if (next === 'rechazada') {
if (agency) {
clean.publish_status = 'idle';
} else {
dispatchN8nWebhookAsync('pipeline.rejected', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
}
if (next === 'borrador' || next === 'revision_agencia') {
if (agency) {
clean.publish_status = 'idle';
}
}
// Agencia envía al cliente → aviso
if (
agency &&
next === 'pendiente_aprobacion' &&
canSendToClient(currentNorm)
) {
dispatchN8nWebhookAsync('pipeline.sent_to_client', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
} else if ('status' in body && body.status != null) {
return NextResponse.json(
{ error: 'status inválido' },
{ status: 400 }
);
}
// Resto de campos: solo agencia los edita.
@@ -229,11 +429,61 @@ export async function PATCH(req: NextRequest) {
if (month) clean.month = month;
const format = str(body.format, 20);
if (format && FORMATS.has(format)) clean.format = format;
if ('caption' in body) clean.caption = str(body.caption, 4000);
if ('caption' in body) {
clean.caption =
sanitizePublishCaption(str(body.caption, 4000) || '', {
maxLen: 4000,
}).caption || null;
}
if ('notes' in body) clean.notes = str(body.notes, 2000);
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
if ('calendar_piece_id' in body) {
clean.calendar_piece_id = str(body.calendar_piece_id, 120);
}
// Scheduling — agency only. Client never receives these keys.
if ('scheduled_at' in body) {
const parsed = parseScheduledAt(body.scheduled_at);
if (parsed === undefined) {
return NextResponse.json(
{ error: 'scheduled_at inválido' },
{ status: 400 }
);
}
clean.scheduled_at = parsed;
if (parsed) {
// Cron publica queued + status aprobada. Programar implica ya validada.
const nextStatus =
typeof clean.status === 'string'
? clean.status
: currentNorm;
if (nextStatus !== 'publicada') {
clean.status = 'aprobada';
clean.approved_by = user.id;
clean.approved_at = new Date().toISOString();
}
clean.publish_status = 'queued';
clean.publish_error = null;
} else {
clean.publish_status = 'idle';
clean.publish_error = null;
}
}
if ('channels' in body) {
clean.channels = normalizeChannels(body.channels);
}
if (
typeof body.publish_status === 'string' &&
(PUBLISH_STATUSES as readonly string[]).includes(body.publish_status)
) {
// Manual reset only — never allow client path here.
clean.publish_status = body.publish_status;
if (body.publish_status === 'idle' || body.publish_status === 'queued') {
clean.publish_error = null;
}
}
}
if (!headers) {
@@ -257,7 +507,47 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data });
let item = Array.isArray(data) ? data[0] : data;
// If schedule is already due (or within 90s), publish in this request
// so we don't depend solely on the Dokploy cron tick.
if (
agency &&
item &&
typeof item.scheduled_at === 'string' &&
item.publish_status === 'queued' &&
item.cover_path
) {
const dueAt = Date.parse(item.scheduled_at);
if (!Number.isNaN(dueAt) && dueAt <= Date.now() + 90_000) {
const pub = await processDuePipelinePublishes({
limit: 1,
tenantId: ctx.tenantId,
itemId: id,
});
const hit = pub.results.find((x) => x.id === item.id);
if (hit) {
// reload row
const again = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
id
)}&select=${SELECT}&limit=1`,
{ headers, cache: 'no-store' }
);
if (again.ok) {
const rows = await again.json();
if (rows?.[0]) item = rows[0];
}
return NextResponse.json({
item,
publishedNow: hit.ok,
publishDetail: hit.detail,
});
}
}
}
return NextResponse.json({ item });
}
export async function DELETE(req: NextRequest) {

View File

@@ -9,15 +9,20 @@ import {
sanitizeFileName,
serviceSupabase,
} from '@/lib/assets';
import { syncPipelineAssetToDrive } from '@/lib/pipeline-drive';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export const maxDuration = 90;
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
/**
* POST multipart: sube portada o slide de una pieza del pipeline.
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
*
* También espeja el archivo en Drive del cliente:
* {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
@@ -36,13 +41,16 @@ export async function POST(req: NextRequest) {
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 });
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
const form = await req.formData();
const file = form.get('file');
const itemId = String(form.get('itemId') || '').trim();
const slot = String(form.get('slot') || 'cover').trim();
const slot = String(form.get('slot') || 'cover').trim() as 'cover' | 'slide';
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
if (!(file instanceof File)) {
@@ -80,19 +88,32 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
// Verifica que la pieza existe y pertenece al tenant activo.
const curRes = await fetch(
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
itemId
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`,
)}&tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&select=id,title,format,caption,notes,scheduled_at,created_at,slide_paths`,
{ headers, cache: 'no-store' }
);
const curRows = curRes.ok ? await curRes.json() : [];
if (!curRows?.[0]) {
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
}
const item = curRows[0] as {
id: string;
title?: string | null;
format?: string | null;
caption?: string | null;
notes?: string | null;
scheduled_at?: string | null;
created_at?: string | null;
slide_paths?: string[];
};
const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase();
const ext = (
sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg'
).toLowerCase();
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
const buf = Buffer.from(await file.arrayBuffer());
@@ -110,8 +131,8 @@ export async function POST(req: NextRequest) {
patch.cover_path = storagePath;
patch.cover_mime = mime;
} else {
const slides: string[] = Array.isArray(curRows[0].slide_paths)
? [...curRows[0].slide_paths]
const slides: string[] = Array.isArray(item.slide_paths)
? [...item.slide_paths]
: [];
slides[index] = storagePath;
patch.slide_paths = slides;
@@ -134,8 +155,28 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
}
const data = await patchRes.json();
const drive = await syncPipelineAssetToDrive({
tenantId: ctx.tenantId,
clientLabel: ctx.label,
item,
buffer: buf,
mimeType: mime,
ext,
slot: slot === 'slide' ? 'slide' : 'cover',
slideIndex: index,
});
return NextResponse.json({
item: Array.isArray(data) ? data[0] : data,
storage_path: storagePath,
drive: {
ok: drive.ok,
skipped: drive.skipped || false,
path: drive.drivePath || null,
fileName: drive.fileName || null,
fileId: drive.driveFileId || null,
warning: drive.warning || null,
},
});
}

View File

@@ -0,0 +1,152 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { userRole } from '@/lib/agency';
import {
sendCreativeToClient,
markCreativeAgencyReview,
approveCreative,
rejectCreative,
retireCreative,
recomposeCreativeInCanva,
requestFeedCopyV2,
requestCreativePartialV2,
} from '@/lib/creatives-workflow';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const role = userRole(user);
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const ctx = await resolveActiveTenant(user);
let tenantId = ctx.tenantId;
if (!tenantId && (role === 'agency_admin' || role === 'agency_member' || role === 'admin')) {
if (body.tenant_id) {
tenantId = body.tenant_id;
} else {
const { getAdminClient } = await import('@/lib/creatives-workflow');
const adminClient = getAdminClient();
const { data: c } = await adminClient.from('creatives').select('tenant_id').eq('id', id).maybeSingle();
if (c?.tenant_id) tenantId = c.tenant_id;
}
}
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant context required' },
{ status: 400 }
);
}
const action = body.action; // 'agency_review' | 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose' | 'copy_v2'
const actor = { role, userId: user.id };
try {
let result: any;
switch (action) {
case 'agency_review':
result = await markCreativeAgencyReview(id, tenantId, actor);
break;
case 'send_to_client':
result = await sendCreativeToClient(id, tenantId, actor);
break;
case 'approve':
result = await approveCreative(id, tenantId, actor, body.comment);
break;
case 'reject':
result = await rejectCreative(id, tenantId, actor, body.comment, {
channel: 'app',
autoRegenerate: Boolean(body.auto_regenerate),
variantsRequested: Number(body.variants_requested) || 1,
});
break;
case 'retire':
result = await retireCreative(id, tenantId, actor, body.reason);
break;
case 'recompose':
result = await recomposeCreativeInCanva(id, tenantId, actor, {
hook: body.hook,
angle: body.angle,
support: body.support,
layout: body.layout,
});
break;
case 'copy_v2': {
const keep = body.keep || {};
result = await requestFeedCopyV2(
id,
tenantId,
actor,
{
primary_indices: keep.primary_indices,
headline_indices: keep.headline_indices,
description: keep.description,
},
body.notes || body.comment || null
);
break;
}
case 'partial_v2': {
const layers = body.layers || {};
const keep = body.keep || body.feed_keep || {};
result = await requestCreativePartialV2(
id,
tenantId,
actor,
{
layers: {
image: layers.image !== false,
hook: layers.hook !== false,
support: layers.support !== false,
},
hook_override: body.hook_override ?? body.hook ?? null,
support_override: body.support_override ?? body.support ?? null,
feed_keep: {
primary_indices: keep.primary_indices,
headline_indices: keep.headline_indices,
description: keep.description,
},
},
body.notes || body.comment || null
);
break;
}
default:
return NextResponse.json(
{ error: `Acción desconocida: ${action}` },
{ status: 400 }
);
}
return NextResponse.json({ ok: true, ...((result && result.creative) ? result : { creative: result }) });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
}

View File

@@ -0,0 +1,187 @@
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';
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 GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
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 ctx = await resolveActiveTenant(user);
const activeTenantId = ctx.tenantId;
const admin = getAdmin();
const { data: creative, error } = await admin
.from('creatives')
.select('*')
.eq('id', id)
.maybeSingle();
if (error || !creative) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
// Si no es agencia, validar pertenencia al tenant
if (!isAgency && creative.tenant_id !== activeTenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
// Obtener métricas diarias
const { data: metrics } = await admin
.from('creative_metrics_daily')
.select('*')
.eq('creative_id', id)
.order('date', { ascending: false })
.limit(30);
// Obtener eventos
const { data: events } = await admin
.from('creative_events')
.select('*')
.eq('creative_id', id)
.order('created_at', { ascending: false })
.limit(20);
// Obtener aprobaciones
const { data: approvals } = await admin
.from('approvals')
.select('*')
.eq('creative_id', id)
.order('created_at', { ascending: false });
const { sanitizeForRole } = await import('@/lib/creatives-visibility');
return NextResponse.json({
creative: sanitizeForRole(creative, role),
metrics: metrics || [],
events: events || [],
approvals: approvals || [],
});
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
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 ctx = await resolveActiveTenant(user);
const activeTenantId = ctx.tenantId;
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const admin = getAdmin();
const { data: existing, error: getErr } = await admin
.from('creatives')
.select('*')
.eq('id', id)
.maybeSingle();
if (getErr || !existing) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
if (!isAgency && existing.tenant_id !== activeTenantId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const prevStatus = existing.status;
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
if (body.headline !== undefined) updates.headline = body.headline;
if (body.primary_text !== undefined) updates.primary_text = body.primary_text;
if (body.cta !== undefined) updates.cta = body.cta;
if (body.angle !== undefined) updates.angle = body.angle;
if (body.hook !== undefined) updates.hook = body.hook;
if (body.campaign_name !== undefined) updates.campaign_name = body.campaign_name;
if (body.adset_id !== undefined) updates.adset_id = body.adset_id;
if (body.format !== undefined) updates.format = body.format;
if (body.visibility !== undefined) updates.visibility = body.visibility;
if (body.status !== undefined) updates.status = body.status;
if (body.meta !== undefined || body.support !== undefined) {
updates.meta = {
...(existing.meta || {}),
...(body.meta && typeof body.meta === 'object' ? body.meta : {}),
...(body.support !== undefined ? { support: body.support } : {}),
};
}
const { data: updated, error: updateErr } = await admin
.from('creatives')
.update(updates)
.eq('id', id)
.select('*')
.single();
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
// Si cambia el status o se sube imagen final, registrar en creative_events
if (updates.status && updates.status !== prevStatus) {
await admin.from('creative_events').insert({
tenant_id: existing.tenant_id,
creative_id: id,
event_type: 'status_change',
actor: `user:${user.id}`,
from_status: prevStatus,
to_status: updates.status,
payload: { changed_by: role, reason: body.status_reason || null },
visibility: updates.status === 'publicada' || updates.status === 'aprobada' ? 'client' : 'agency',
});
} else if (updates.asset_final && !existing.asset_final) {
await admin.from('creative_events').insert({
tenant_id: existing.tenant_id,
creative_id: id,
event_type: 'asset_final_uploaded',
actor: `user:${user.id}`,
from_status: prevStatus,
to_status: prevStatus,
payload: { asset_url: updates.asset_url },
visibility: 'agency',
});
}
return NextResponse.json({ ok: true, creative: updated });
}

View File

@@ -1,17 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createClient } from '@/lib/supabase/server';
import {
VIEW_TENANT_COOKIE,
homeTenantId,
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { sanitizeForRole } from '@/lib/creatives-visibility';
import { createClient as createAdminClient } from '@supabase/supabase-js';
const SELECT =
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at';
'id,tenant_id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,campaign_name,adset_id,meta_ad_id,angle,hook,asset_url,thumbnail_url,visibility,parent_id,generation_request_id,asset_final,created_by,created_at,updated_at';
const CHANNELS = new Set([
'meta_ads',
@@ -39,20 +39,17 @@ const FORMATS = new Set([
'youtube',
'other',
]);
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
const ALLOWED_STATUS = new Set([
'draft',
'in_review',
'approved',
'rejected',
'published',
]);
async function resolveTenantId(user: {
app_metadata?: Record<string, unknown>;
user_metadata?: Record<string, unknown>;
}): Promise<{ tenantId: string | null; viaView: boolean }> {
const role = userRole(user);
const cookieStore = await cookies();
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
if (isAgencyRole(role) && viewId) {
return { tenantId: viewId, viaView: true };
}
return { tenantId: homeTenantId(user), viaView: false };
}
const CLIENT_ALLOWED_TRANSITIONS: Record<string, string[]> = {
in_review: ['approved', 'rejected'],
};
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
@@ -78,7 +75,24 @@ function previewFields(body: Record<string, unknown>) {
};
}
export async function GET() {
async function fetchCurrentStatus(
id: string,
tenantId: string | null,
headers: Record<string, string> | null
): Promise<string | null> {
if (headers && tenantId) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(tenantId)}&select=status,meta`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = (await r.json()) as { status?: string }[];
return rows[0]?.status || null;
}
return null;
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
@@ -87,70 +101,113 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tenantId, viaView } = await resolveTenantId(user);
const role = userRole(user);
const isAgency = isAgencyRole(role);
const { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
if (viaView && tenantId) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
{ creatives: [], warning: 'Service role required for superadmin view' },
{ status: 200 }
);
}
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
// Fallback if preview columns not migrated yet
const fallback = await fetch(
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
{ headers, cache: 'no-store' }
);
if (fallback.ok) {
return NextResponse.json({
creatives: await fallback.json(),
viewTenantId: tenantId,
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
}
return NextResponse.json({
creatives: [],
warning: await r.text(),
viewTenantId: tenantId,
});
}
return NextResponse.json({
creatives: await r.json(),
viewTenantId: tenantId,
});
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
const { data, error } = await supabase
if (!tenantId) {
return NextResponse.json({ creatives: [], isAgency });
}
const adminUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const adminKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const admin = createAdminClient(adminUrl, adminKey);
let query = admin
.from('creatives')
.select(SELECT)
.eq('tenant_id', tenantId)
.order('updated_at', { ascending: false });
if (error) {
const { data: basic, error: e2 } = await supabase
.from('creatives')
.select('id,title,status,mime_type,bytes,created_at,updated_at')
.order('updated_at', { ascending: false });
if (e2) {
return NextResponse.json(
{ creatives: [], warning: error.message },
{ status: 200 }
);
}
return NextResponse.json({
creatives: basic ?? [],
warning:
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
});
// Si es cliente, aplicar visibilidad estricta
if (!isAgency) {
query = query
.eq('visibility', 'client')
.in('status', ['pendiente_aprobacion', 'aprobada', 'publicada', 'fatigada', 'retirada', 'rechazada']);
}
return NextResponse.json({ creatives: data ?? [] });
const { data: rows, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const creatives = rows || [];
// Obtener métricas agregadas de los últimos 30 días para estas creatividades
const creativeIds = creatives.map((c: any) => c.id);
const metricsMap: Record<string, any> = {};
if (creativeIds.length > 0) {
const { data: metrics } = await admin
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results, frequency, ctr, cpa')
.in('creative_id', creativeIds);
if (metrics && metrics.length > 0) {
for (const m of metrics) {
const cid = m.creative_id;
metricsMap[cid] = metricsMap[cid] || {
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
count: 0,
freq_sum: 0,
ctr_sum: 0,
cpa_sum: 0,
cpa_count: 0,
};
metricsMap[cid].impressions += Number(m.impressions || 0);
metricsMap[cid].clicks += Number(m.clicks || 0);
metricsMap[cid].spend += Number(m.spend || 0);
metricsMap[cid].results += Number(m.results || 0);
metricsMap[cid].count += 1;
metricsMap[cid].freq_sum += Number(m.frequency || 0);
metricsMap[cid].ctr_sum += Number(m.ctr || 0);
if (m.cpa != null) {
metricsMap[cid].cpa_sum += Number(m.cpa);
metricsMap[cid].cpa_count += 1;
}
}
}
}
// Adjuntar métricas resumidas
const enriched = creatives.map((c: any) => {
const agg = metricsMap[c.id];
const metrics = agg
? {
impressions: agg.impressions,
clicks: agg.clicks,
spend: Number(agg.spend.toFixed(2)),
results: agg.results,
frequency: agg.count > 0 ? Number((agg.freq_sum / agg.count).toFixed(2)) : 0,
ctr: agg.impressions > 0 ? Number(((agg.clicks / agg.impressions) * 100).toFixed(2)) : 0,
cpa: agg.cpa_count > 0 ? Number((agg.cpa_sum / agg.cpa_count).toFixed(2)) : null,
}
: null;
return {
...c,
metrics,
};
});
return NextResponse.json({
creatives: sanitizeForRole(enriched, role),
isAgency,
tenantId,
});
}
export async function POST(req: NextRequest) {
@@ -162,9 +219,19 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { tenantId, viaView } = await resolveTenantId(user);
if (!tenantId) {
return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 });
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json(
{ error: 'Solo el equipo de 4Ventures puede crear piezas' },
{ status: 403 }
);
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
@@ -179,9 +246,14 @@ export async function POST(req: NextRequest) {
status: 'draft',
created_by: user.id,
...previewFields(body),
meta: {
month: str(body.month, 7),
notes: str(body.notes, 2000),
instagram_url: str(body.instagram_url, 500),
},
};
if (viaView) {
if (ctx.viaView) {
const headers = serviceHeaders();
if (!headers) {
return NextResponse.json(
@@ -268,13 +340,81 @@ export async function PATCH(req: NextRequest) {
for (const [k, v] of Object.entries(previewFields(body))) {
if (k in body) clean[k] = v;
}
if ('month' in body || 'notes' in body || 'instagram_url' in body) {
clean.meta = {
month: str(body.month, 7),
notes: str(body.notes, 2000),
instagram_url: str(body.instagram_url, 500),
};
}
const agency = isAgencyRole(userRole(user));
const headers = serviceHeaders();
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId || (agency && ctx.portfolioWide)) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
if (!agency) {
const current = await fetchCurrentStatus(id, tenantId, headers);
const next =
typeof body.status === 'string' && ALLOWED_STATUS.has(body.status)
? body.status
: '';
const allowed = CLIENT_ALLOWED_TRANSITIONS[current || ''] || [];
if (!allowed.includes(next)) {
return NextResponse.json(
{
error:
'Como cliente solo puedes aprobar o rechazar una pieza en revisión interna',
},
{ status: 403 }
);
}
const clientPatch = {
status: next,
updated_at: new Date().toISOString(),
};
if (headers && tenantId) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(clientPatch),
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ creative: data[0] || data });
}
const { data, error } = await supabase
.from('creatives')
.update(clientPatch)
.eq('id', id)
.select(SELECT)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ creative: data });
}
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
@@ -289,13 +429,21 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const data = await r.json();
return NextResponse.json({ creative: data[0] || data });
const row = Array.isArray(data) ? data[0] : data;
if (!row) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
);
}
return NextResponse.json({ creative: row });
}
const { data, error } = await supabase
.from('creatives')
.update(clean)
.eq('id', id)
.eq('tenant_id', tenantId)
.select(SELECT)
.single();
if (error) {
@@ -319,20 +467,52 @@ export async function DELETE(req: NextRequest) {
}
const agency = isAgencyRole(userRole(user));
if (!agency) {
return NextResponse.json(
{ error: 'Solo el equipo de 4Ventures puede borrar 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 headers = serviceHeaders();
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}`,
{ method: 'DELETE', headers }
const del = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(
id
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'DELETE',
headers: {
...headers,
Prefer: 'return=representation',
},
}
);
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
if (!del.ok) {
return NextResponse.json({ error: await del.text() }, { status: 500 });
}
const rows = await del.json().catch(() => []);
if (Array.isArray(rows) && rows.length === 0) {
return NextResponse.json(
{ error: 'Creatividad no encontrada en este cliente' },
{ status: 404 }
);
}
return NextResponse.json({ ok: true });
}
const { error } = await supabase.from('creatives').delete().eq('id', id);
const { error } = await supabase
.from('creatives')
.delete()
.eq('id', id)
.eq('tenant_id', ctx.tenantId);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}

View File

@@ -0,0 +1,366 @@
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 {
ClientCreativeStyle,
compileClientSkillMarkdown,
normalizeCreativeStyle,
} from '@/lib/creative-style';
import {
compileClientSkillFromBrandbook,
parseBrandbook,
seedBrandbook,
type Brandbook,
} from '@/lib/brandbook';
export const dynamic = 'force-dynamic';
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 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);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { searchParams } = new URL(req.url);
let tenantId = searchParams.get('tenant_id') || searchParams.get('tenantId');
if (!tenantId) {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
const { data: tenant, error } = await admin
.from('tenants')
.select(
'id, name, slug, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products, creative_style, settings'
)
.eq('id', tenantId)
.maybeSingle();
if (error || !tenant) {
return NextResponse.json({ error: error?.message || 'Cliente no encontrado' }, { status: 404 });
}
const rawStyle =
tenant.creative_style && Object.keys(tenant.creative_style).length > 0
? tenant.creative_style
: (tenant.settings as any)?.creative_style;
const style = normalizeCreativeStyle(rawStyle, tenant.name || 'Cliente', tenant.slug || '');
// Cargar brandbook de documents (fuente central de verdad)
const { data: brandDoc } = await admin
.from('documents')
.select('content')
.eq('tenant_id', tenantId)
.eq('type', 'brandbook')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
let brandbook: Brandbook | null = null;
if (brandDoc?.content) {
try {
const parsedContent =
typeof brandDoc.content === 'string'
? JSON.parse(brandDoc.content)
: brandDoc.content;
brandbook = parseBrandbook(parsedContent);
} catch {
brandbook = null;
}
}
if (!brandbook) {
brandbook = seedBrandbook(tenant.slug || 'cliente', tenant.name || 'Cliente');
} else {
if (brandbook.website) style.identity.website = brandbook.website;
if (brandbook.b2b_offering) style.identity.b2b_offering = brandbook.b2b_offering;
if (brandbook.b2c_offering) style.identity.b2c_offering = brandbook.b2c_offering;
if (brandbook.usp_differentiators) style.identity.usp_differentiators = brandbook.usp_differentiators;
if (brandbook.current_priorities) style.identity.current_priorities = brandbook.current_priorities;
if (brandbook.price_and_offer_framing) style.identity.price_and_offer_framing = brandbook.price_and_offer_framing;
if (brandbook.elevator_pitch) style.identity.summary = brandbook.elevator_pitch;
if (brandbook.tone) style.voice.tone = brandbook.tone;
if (brandbook.copy_structure_rules) style.voice.copy_structure_rules = brandbook.copy_structure_rules;
if (brandbook.english_terms_policy) style.voice.english_terms_policy = brandbook.english_terms_policy;
if (brandbook.power_words?.length) style.voice.power_words = brandbook.power_words;
if (brandbook.allowed_ctas?.length) style.voice.allowed_ctas = brandbook.allowed_ctas;
if (brandbook.surface_rules) style.visual.surface_rules = brandbook.surface_rules;
if (brandbook.image_text_rules) style.visual.image_text_rules = brandbook.image_text_rules;
if (brandbook.typography_headline) style.visual.typography_headline = brandbook.typography_headline;
if (brandbook.typography_body) style.visual.typography_body = brandbook.typography_body;
if (brandbook.b2b_personas?.length) style.audiences.b2b_personas = brandbook.b2b_personas as any;
if (brandbook.b2c_personas?.length) style.audiences.b2c_personas = brandbook.b2c_personas as any;
if (brandbook.customer_frustrations) style.audiences.customer_frustrations = brandbook.customer_frustrations;
if (brandbook.desired_transformation) style.audiences.desired_transformation = brandbook.desired_transformation;
if (brandbook.objections?.length) style.arguments.objections = brandbook.objections as any;
if (brandbook.winning_hooks?.length) style.arguments.winning_hooks = brandbook.winning_hooks;
if (brandbook.preferred_angles?.length) style.arguments.preferred_angles = brandbook.preferred_angles;
if (brandbook.emoji_policy) style.prohibitions.emoji_policy = brandbook.emoji_policy;
if (brandbook.testimonials_policy) style.prohibitions.testimonials_policy = brandbook.testimonials_policy;
if (brandbook.competitors_never_mention?.length) style.prohibitions.competitors_never_mention = brandbook.competitors_never_mention;
}
const compiledMarkdown = compileClientSkillFromBrandbook(
brandbook,
{
image_ai: style.image_ai,
campaign_rules: style.campaign_rules,
},
tenant.name || 'Cliente',
tenant.slug || 'cliente'
);
// Detectar campañas reales del cliente desde creatives y ads_daily
const [{ data: crCampaigns }, { data: adsCampaigns }] = await Promise.all([
admin
.from('creatives')
.select('campaign_name')
.eq('tenant_id', tenantId)
.not('campaign_name', 'is', null),
admin
.from('ads_daily')
.select('campaign_name')
.eq('tenant_id', tenantId)
.not('campaign_name', 'is', null)
.limit(500),
]);
const detectedSet = new Set<string>();
for (const row of crCampaigns || []) {
if (row.campaign_name?.trim()) detectedSet.add(row.campaign_name.trim());
}
for (const row of adsCampaigns || []) {
if ((row as any).campaign_name?.trim()) detectedSet.add((row as any).campaign_name.trim());
}
return NextResponse.json({
tenant_id: tenant.id,
name: tenant.name,
slug: tenant.slug,
creatives_enabled: Boolean(tenant.creatives_enabled),
meta_ad_account_id: tenant.meta_ad_account_id || '',
meta_page_id: tenant.meta_page_id || '',
meta_ig_user_id: tenant.meta_ig_user_id || '',
slack_channel_id: tenant.slack_channel_id || '',
drive_folder_id: tenant.drive_folder_id || '',
default_landing_url: tenant.default_landing_url || '',
canva_templates: tenant.canva_templates || {},
campaign_audience: tenant.campaign_audience || {},
campaign_products: tenant.campaign_products || {},
detected_campaigns: Array.from(detectedSet).sort(),
style,
compiled_skill: compiledMarkdown,
});
}
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 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const tenantId = body.tenant_id || body.tenantId;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
const { data: existing, error: getErr } = await admin
.from('tenants')
.select('id, name, slug, settings')
.eq('id', tenantId)
.maybeSingle();
if (getErr || !existing) {
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
}
const normalizedStyle: ClientCreativeStyle = normalizeCreativeStyle(
body.style,
existing.name || 'Cliente',
existing.slug || ''
);
normalizedStyle.updated_at = new Date().toISOString();
const updatePayload: Record<string, any> = {
creative_style: normalizedStyle,
settings: {
...(existing.settings || {}),
creative_style: normalizedStyle,
},
};
if (typeof body.creatives_enabled === 'boolean') {
updatePayload.creatives_enabled = body.creatives_enabled;
}
if (typeof body.meta_ad_account_id === 'string') {
updatePayload.meta_ad_account_id = body.meta_ad_account_id.trim() || null;
}
if (typeof body.meta_page_id === 'string') {
updatePayload.meta_page_id = body.meta_page_id.trim() || null;
}
if (typeof body.meta_ig_user_id === 'string') {
updatePayload.meta_ig_user_id = body.meta_ig_user_id.trim() || null;
}
if (typeof body.slack_channel_id === 'string') {
updatePayload.slack_channel_id = body.slack_channel_id.trim() || null;
}
if (typeof body.drive_folder_id === 'string') {
updatePayload.drive_folder_id = body.drive_folder_id.trim() || null;
}
if (typeof body.default_landing_url === 'string') {
updatePayload.default_landing_url = body.default_landing_url.trim() || null;
}
if (body.canva_templates && typeof body.canva_templates === 'object') {
updatePayload.canva_templates = body.canva_templates;
}
if (body.campaign_audience && typeof body.campaign_audience === 'object') {
updatePayload.campaign_audience = body.campaign_audience;
}
if (body.campaign_products && typeof body.campaign_products === 'object') {
updatePayload.campaign_products = body.campaign_products;
}
const { error: updErr } = await admin
.from('tenants')
.update(updatePayload)
.eq('id', tenantId);
if (updErr) {
return NextResponse.json({ error: updErr.message }, { status: 500 });
}
// Cargar brandbook de documents para compilar skill actualizado
const { data: brandDoc } = await admin
.from('documents')
.select('content')
.eq('tenant_id', tenantId)
.eq('type', 'brandbook')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
let brandbook: Brandbook | null = null;
if (brandDoc?.content) {
try {
const parsedContent =
typeof brandDoc.content === 'string'
? JSON.parse(brandDoc.content)
: brandDoc.content;
brandbook = parseBrandbook(parsedContent);
} catch {
brandbook = null;
}
}
if (!brandbook) {
brandbook = seedBrandbook(existing.slug || 'cliente', existing.name || 'Cliente');
}
// Sincronizar simultáneamente con documents (brandbook) para que Zona de Marca esté siempre al día
const updatedBrandbook: Brandbook = {
...brandbook,
elevator_pitch: normalizedStyle.identity.summary || brandbook.elevator_pitch,
website: normalizedStyle.identity.website || brandbook.website,
b2b_offering: normalizedStyle.identity.b2b_offering || brandbook.b2b_offering,
b2c_offering: normalizedStyle.identity.b2c_offering || brandbook.b2c_offering,
usp_differentiators: normalizedStyle.identity.usp_differentiators || brandbook.usp_differentiators,
current_priorities: normalizedStyle.identity.current_priorities || brandbook.current_priorities,
price_and_offer_framing: normalizedStyle.identity.price_and_offer_framing || brandbook.price_and_offer_framing,
tone: normalizedStyle.voice.tone || brandbook.tone,
copy_structure_rules: normalizedStyle.voice.copy_structure_rules || brandbook.copy_structure_rules,
english_terms_policy: normalizedStyle.voice.english_terms_policy || brandbook.english_terms_policy,
power_words: normalizedStyle.voice.power_words?.length ? normalizedStyle.voice.power_words : brandbook.power_words,
allowed_ctas: normalizedStyle.voice.allowed_ctas?.length ? normalizedStyle.voice.allowed_ctas : brandbook.allowed_ctas,
surface_rules: normalizedStyle.visual.surface_rules || brandbook.surface_rules,
image_text_rules: normalizedStyle.visual.image_text_rules || brandbook.image_text_rules,
typography_headline: normalizedStyle.visual.typography_headline || brandbook.typography_headline,
typography_body: normalizedStyle.visual.typography_body || brandbook.typography_body,
b2b_personas: normalizedStyle.audiences.b2b_personas?.length ? (normalizedStyle.audiences.b2b_personas as any) : brandbook.b2b_personas,
b2c_personas: normalizedStyle.audiences.b2c_personas?.length ? (normalizedStyle.audiences.b2c_personas as any) : brandbook.b2c_personas,
customer_frustrations: normalizedStyle.audiences.customer_frustrations || brandbook.customer_frustrations,
desired_transformation: normalizedStyle.audiences.desired_transformation || brandbook.desired_transformation,
objections: normalizedStyle.arguments.objections?.length ? (normalizedStyle.arguments.objections as any) : brandbook.objections,
winning_hooks: normalizedStyle.arguments.winning_hooks?.length ? normalizedStyle.arguments.winning_hooks : brandbook.winning_hooks,
preferred_angles: normalizedStyle.arguments.preferred_angles?.length ? normalizedStyle.arguments.preferred_angles : brandbook.preferred_angles,
emoji_policy: normalizedStyle.prohibitions.emoji_policy || brandbook.emoji_policy,
testimonials_policy: normalizedStyle.prohibitions.testimonials_policy || brandbook.testimonials_policy,
competitors_never_mention: normalizedStyle.prohibitions.competitors_never_mention?.length ? normalizedStyle.prohibitions.competitors_never_mention : brandbook.competitors_never_mention,
updated_at: new Date().toISOString(),
};
if (brandDoc) {
await admin
.from('documents')
.update({
content: updatedBrandbook,
updated_at: updatedBrandbook.updated_at,
})
.eq('id', (brandDoc as any).id);
} else {
await admin
.from('documents')
.insert({
tenant_id: tenantId,
type: 'brandbook',
title: 'Brandbook',
status: 'approved',
content: updatedBrandbook,
updated_at: updatedBrandbook.updated_at,
});
}
const compiledMarkdown = compileClientSkillFromBrandbook(
updatedBrandbook,
{
image_ai: normalizedStyle.image_ai,
campaign_rules: normalizedStyle.campaign_rules,
},
existing.name || 'Cliente',
existing.slug || 'cliente'
);
return NextResponse.json({
ok: true,
style: normalizedStyle,
compiled_skill: compiledMarkdown,
});
}

View File

@@ -98,7 +98,7 @@ export async function POST(req: NextRequest) {
const row = {
tenant_id: ctx.tenantId,
title: title.slice(0, 300),
status: 'draft',
status: 'borrador',
channel: 'meta_ads',
format: mime.startsWith('video/') ? 'video' : 'image',
created_by: user.id,
@@ -150,8 +150,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: up.error.message }, { status: 500 });
}
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
const patch = {
const mediaUrl = `/api/creatives/media?path=${encodeURIComponent(storagePath)}`;
const isFinal =
form.get('is_final') === 'true' ||
form.get('asset_final') === 'true';
const patch: Record<string, any> = {
storage_path: storagePath,
mime_type: mime,
bytes: file.size,
@@ -159,6 +164,12 @@ export async function POST(req: NextRequest) {
updated_at: new Date().toISOString(),
};
if (isFinal) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.4ventures.es';
patch.asset_url = `${appUrl.replace(/\/$/, '')}${mediaUrl}`;
patch.asset_final = true;
}
if (agency && headers) {
const r = await fetch(
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Clientify reconciliation (source of truth for CRM deals/leads).
* Webhooks are best-effort; WooCommerce→Clientify often skips outbound events.
*
* Dokploy (daily ~05:15 UTC):
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/clientify-sync?lookback=60"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const lookbackDays = Math.min(
120,
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
);
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
try {
const { results } = await syncAllClientifyTenants({
tenantId,
lookbackDays,
});
const withErrors = results.filter((r) => r.error);
return NextResponse.json(
{
ok: withErrors.length === 0,
at: new Date().toISOString(),
lookbackDays,
synced: results.filter((r) => !r.error).length,
withErrors: withErrors.length,
results,
},
{ status: withErrors.length ? 207 : 200 }
);
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'clientify-sync failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -8,6 +8,10 @@ import {
superhumanConfigured,
} from '@/lib/superhuman-docs';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
import { runAgencyHealthAlerts } from '@/lib/agency-health';
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
@@ -90,14 +94,30 @@ export async function GET(req: NextRequest) {
};
}
// 4) Clientify CRM → leads/deals
// 4) CRM + Shopify (prefer dedicated crons; safety net here)
try {
const crm = await syncAllClientifyTenants();
const lookback = Math.max(days * 2, 30);
const [clientify, gohighlevel, shopify] = await Promise.all([
syncAllClientifyTenants({ lookbackDays: lookback }),
syncAllGhlTenants({ lookbackDays: lookback }),
syncAllShopifyTenants({ lookbackDays: lookback }),
]);
const results = [
...clientify.results,
...gohighlevel.results,
...shopify.results.map((r) => ({
tenantId: r.tenantId,
tenantSlug: r.tenantSlug,
leads: 0,
deals: r.orders,
error: r.error,
})),
];
out.crm = {
ok: true,
synced: crm.results.filter((r) => !r.error).length,
withErrors: crm.results.filter((r) => r.error).length,
results: crm.results,
synced: results.filter((r) => !r.error).length,
withErrors: results.filter((r) => r.error).length,
results,
};
} catch (e) {
out.crm = {
@@ -106,6 +126,26 @@ export async function GET(req: NextRequest) {
};
}
// 5) Pipeline Meta publish (safety net; prefer dedicated */10 cron)
try {
out.pipelinePublish = await processDuePipelinePublishes({ limit: 5 });
} catch (e) {
out.pipelinePublish = {
ok: false,
error: e instanceof Error ? e.message : 'pipeline-publish failed',
};
}
// 6) Health alerts (stale sync / ecommerce drop)
try {
out.healthAlerts = await runAgencyHealthAlerts();
} catch (e) {
out.healthAlerts = {
ok: false,
error: e instanceof Error ? e.message : 'health-alerts failed',
};
}
const failed = ['organic', 'ads', 'superhuman', 'crm'].some(
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
);

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily GoHighLevel reconciliation (upsert, no wipe).
* Dokploy ~05:20 UTC:
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/ghl-sync"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const lookbackDays = Math.min(
120,
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
);
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
try {
const { results } = await syncAllGhlTenants({ tenantId, lookbackDays });
const withErrors = results.filter((r) => r.error);
return NextResponse.json(
{
ok: withErrors.length === 0,
at: new Date().toISOString(),
lookbackDays,
synced: results.filter((r) => !r.error).length,
withErrors: withErrors.length,
results,
},
{ status: withErrors.length ? 207 : 200 }
);
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'ghl-sync failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { runAgencyHealthAlerts } from '@/lib/agency-health';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
/**
* Daily agency health → agency_alerts (stale sync, ecommerce drop).
* Dokploy ~07:00 UTC:
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/health-alerts"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
try {
const result = await runAgencyHealthAlerts();
return NextResponse.json({
ok: true,
at: new Date().toISOString(),
...result,
});
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'health-alerts failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,37 @@
/**
* Cron: publish due creative_pipeline rows to Meta IG+FB.
*
* Auth: Authorization: Bearer $CRON_SECRET
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/pipeline-publish"
*/
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export const maxDuration = 120;
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const limit = Number(req.nextUrl.searchParams.get('limit') || 10);
const dryRun = req.nextUrl.searchParams.get('dry') === '1';
const tenantId =
req.nextUrl.searchParams.get('tenantId')?.trim() || undefined;
const out = await processDuePipelinePublishes({ limit, dryRun, tenantId });
if (!out.ok && out.error) {
return NextResponse.json(
{ error: out.error },
{ status: 500 }
);
}
return NextResponse.json(out);
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Shopify paid-orders → deals.
* Requires integrations.meta.access_token + shop / external_account_id.
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const lookbackDays = Math.min(
120,
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
);
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
try {
const { results } = await syncAllShopifyTenants({
tenantId,
lookbackDays,
});
const withErrors = results.filter((r) => r.error);
return NextResponse.json(
{
ok: withErrors.length === 0,
at: new Date().toISOString(),
lookbackDays,
synced: results.filter((r) => !r.error).length,
withErrors: withErrors.length,
results,
},
{ status: withErrors.length ? 207 : 200 }
);
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'shopify-sync failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,125 @@
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';
const DEFAULT_RULES = {
lookback_days: 7,
min_impressions: 1000,
max_frequency: 2.8,
ctr_drop_pct: 35,
cpa_rise_pct: 40,
signals_required: 2,
auto_request_relief: true,
};
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 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 { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
}
const admin = getAdmin();
const { data: rules, error } = await admin
.from('fatigue_rules')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (!rules) {
return NextResponse.json({
rules: {
tenant_id: tenantId,
...DEFAULT_RULES,
},
isDefault: true,
});
}
return NextResponse.json({ rules, isDefault: false });
}
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 });
}
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Solo la agencia puede modificar reglas de fatiga' }, { status: 403 });
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const tenantId = body.tenant_id;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const payload = {
tenant_id: tenantId,
lookback_days: Math.max(1, Number(body.lookback_days || DEFAULT_RULES.lookback_days)),
min_impressions: Math.max(0, Number(body.min_impressions || DEFAULT_RULES.min_impressions)),
max_frequency: Math.max(1, Number(body.max_frequency || DEFAULT_RULES.max_frequency)),
ctr_drop_pct: Math.max(1, Number(body.ctr_drop_pct || DEFAULT_RULES.ctr_drop_pct)),
cpa_rise_pct: Math.max(1, Number(body.cpa_rise_pct || DEFAULT_RULES.cpa_rise_pct)),
signals_required: Math.min(3, Math.max(1, Number(body.signals_required || DEFAULT_RULES.signals_required))),
auto_request_relief: body.auto_request_relief !== undefined ? Boolean(body.auto_request_relief) : DEFAULT_RULES.auto_request_relief,
updated_at: new Date().toISOString(),
};
const admin = getAdmin();
const { data: updated, error } = await admin
.from('fatigue_rules')
.upsert(payload, { onConflict: 'tenant_id' })
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true, rules: updated });
}

View File

@@ -0,0 +1,237 @@
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 { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
import {
isRrssPipelineFormat,
rrssBriefAspectFields,
} from '@/lib/rrss-generation';
import { createClient as createAdminClient } from '@supabase/supabase-js';
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 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 { searchParams } = new URL(req.url);
const queryTenantId = searchParams.get('tenant_id');
let tenantId: string | null = null;
if (isAgency && queryTenantId) {
tenantId = queryTenantId;
} else {
const ctx = await resolveActiveTenant(user);
tenantId = ctx.tenantId;
}
if (!tenantId) {
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
}
const admin = getAdmin();
const { data: requests, error } = await admin
.from('generation_requests')
.select('*')
.eq('tenant_id', tenantId)
.order('requested_at', { ascending: false })
.limit(50);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ requests });
}
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
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: 'Unauthorized' }, { status: 401 });
}
if (user) {
const role = userRole(user);
if (!isAgencyRole(role)) {
return NextResponse.json({ error: 'Solo la agencia puede solicitar relevos a Claude' }, { status: 403 });
}
}
const tenantId = body.tenant_id || body.client_id;
if (!tenantId) {
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
}
const admin = getAdmin();
// Get tenant slug and maps
const { data: tenant } = await admin
.from('tenants')
.select('slug, name, campaign_audience, campaign_products')
.eq('id', tenantId)
.maybeSingle();
// Si viene source_creative_id, obtener datos de la creatividad fatigada
let creativeCampaignName: string | null = null;
let creativeAdsetId: string | null = null;
let creativeAngle: string | null = null;
if (body.source_creative_id) {
const { data: srcCreative } = await admin
.from('creatives')
.select('campaign_name, adset_id, angle')
.eq('id', body.source_creative_id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (srcCreative) {
creativeCampaignName = srcCreative.campaign_name || null;
creativeAdsetId = srcCreative.adset_id || null;
creativeAngle = srcCreative.angle || null;
}
}
// Priorizar campaign_name y adset_id de la creatividad de origen si existe
const campaignName = creativeCampaignName || body.campaign_name || null;
const adsetId = creativeAdsetId || body.adset_id || null;
const campaignAudienceMap = (tenant as any)?.campaign_audience || {};
let audience: 'b2b' | 'b2c' | null = body.audience || null;
if (!audience && campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) {
const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim();
if (val === 'b2b' || val === 'b2c') {
audience = val;
}
}
const campaignProductsMap = (tenant as any)?.campaign_products || {};
let product: string | null = body.product || null;
let landingUrl: string | null = body.landing_url || null;
if (campaignName && campaignProductsMap && typeof campaignProductsMap === 'object' && campaignProductsMap[campaignName]) {
const pVal = campaignProductsMap[campaignName];
if (typeof pVal === 'string') {
if (!product) product = pVal;
} else if (typeof pVal === 'object' && pVal !== null) {
if (!product) product = pVal.product ? String(pVal.product) : null;
if (!landingUrl) landingUrl = pVal.landing_url ? String(pVal.landing_url) : null;
}
}
const avoidAngles = body.avoid_angles || (creativeAngle ? [creativeAngle] : []);
const channel =
body.channel === 'rrss' || body.brief?.channel === 'rrss' ? 'rrss' : 'ads';
const format =
body.format ||
(channel === 'rrss' ? 'post' : 'estatico');
const aspectFields =
channel === 'rrss' && isRrssPipelineFormat(format)
? rrssBriefAspectFields(format)
: staticAdBriefAspectFields(format);
const brief = {
tenant_id: tenantId,
client_id: tenantId,
client_slug: tenant?.slug || 'cliente',
client_name: tenant?.name || null,
channel,
reason: body.reason || (channel === 'rrss' ? 'calendario' : 'manual'),
source_creative_id:
channel === 'rrss' ? null : body.source_creative_id || null,
source_pipeline_id: body.source_pipeline_id || null,
calendar_piece_id: body.calendar_piece_id || null,
format,
skill_format: body.skill_format || format,
...aspectFields,
variants_requested: Number(
body.variants_requested || (channel === 'rrss' ? 2 : 3)
),
campaign_name: channel === 'rrss' ? null : campaignName,
adset_id: channel === 'rrss' ? null : adsetId,
audience: channel === 'rrss' ? null : audience,
product: channel === 'rrss' ? null : product,
landing_url: channel === 'rrss' ? null : landingUrl,
avoid_angles: avoidAngles,
title: body.title || null,
caption: body.caption || null,
pillar: body.pillar || null,
month: body.month || null,
scheduled_at: body.scheduled_at || null,
calendar_piece: body.calendar_piece || null,
context: body.context || null,
write_back: body.write_back || null,
notes: body.notes || null,
};
const { data: created, error } = await admin
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: brief.reason,
source_creative_id: brief.source_creative_id,
brief,
status: 'pendiente',
})
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Registrar en creative_events (solo Ads: FK a creatives)
if (channel === 'ads' && brief.source_creative_id) {
await admin.from('creative_events').insert({
tenant_id: tenantId,
creative_id: brief.source_creative_id,
event_type: 'generation_requested',
actor: user ? `user:${user.id}` : 'agency',
from_status: null,
to_status: null,
payload: {
generation_request_id: created.id,
reason: brief.reason,
variants_requested: brief.variants_requested,
},
visibility: 'agency',
});
}
const enrichedRequest = { ...created, client_id: created.tenant_id };
dispatchN8nWebhookAsync('generation_request.created', {
generation_request: enrichedRequest,
});
return NextResponse.json({ ok: true, request: enrichedRequest });
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: false });
if (!auth.ok) {
return auth.errorResponse!;
}
const creativesEnabled = req.nextUrl.searchParams.get('creatives_enabled');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
let query = adminClient
.from('tenants')
.select('id, name, slug, meta_ad_account_id, slack_channel_id, drive_folder_id, creatives_enabled');
if (creativesEnabled === 'true') {
query = query.eq('creatives_enabled', true);
}
const { data: clients, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Se devuelven mapeando id tanto a client_id como a tenant_id para compatibilidad total con n8n
const mapped = (clients || []).map((c: any) => ({
...c,
client_id: c.id,
tenant_id: c.id,
}));
return NextResponse.json(mapped);
}

View File

@@ -0,0 +1,240 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
import {
parseGenerationRequestIdFromNotes,
stripGenerationRequestMarker,
} from '@/lib/rrss-generation';
export const dynamic = 'force-dynamic';
function admin() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
async function downloadToStorage(opts: {
tenantId: string;
pipelineId: string;
url: string;
filename: string;
}): Promise<string | null> {
const svc = serviceSupabase();
if (!svc) return null;
try {
const r = await fetch(opts.url, { cache: 'no-store' });
if (!r.ok) return null;
const buf = Buffer.from(await r.arrayBuffer());
const contentType = r.headers.get('content-type') || 'image/jpeg';
const ext = contentType.includes('png')
? 'png'
: contentType.includes('webp')
? 'webp'
: 'jpg';
const path = `${opts.tenantId}/pipeline/${opts.pipelineId}/${opts.filename}.${ext}`;
const up = await svc.storage.from(ASSETS_BUCKET).upload(path, buf, {
contentType,
upsert: true,
});
if (up.error) return null;
return path;
} catch {
return null;
}
}
/**
* POST — n8n write-back for RRSS auto-generation.
* Body: {
* generation_request_id?,
* pipeline_id?,
* asset_url?, // final or Freepik raw
* stories_asset_url?,
* caption?,
* hook?, support?,
* slide_urls?: string[],
* status?: 'borrador'|'revision_agencia',
* mark_request?: 'completada'|'fallida',
* error?: string
* }
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, {
requireTenant: true,
parsedBody: body,
});
if (!auth.ok) return auth.errorResponse!;
const tenantId = auth.tenantId!;
const db = admin();
const grId =
typeof body.generation_request_id === 'string'
? body.generation_request_id
: null;
let pipelineId =
typeof body.pipeline_id === 'string' ? body.pipeline_id : null;
if (!pipelineId && grId) {
const { data: gr } = await db
.from('generation_requests')
.select('brief')
.eq('id', grId)
.eq('tenant_id', tenantId)
.maybeSingle();
const brief = (gr?.brief || {}) as {
source_pipeline_id?: string;
write_back?: { pipeline_id?: string };
};
pipelineId =
brief.source_pipeline_id || brief.write_back?.pipeline_id || null;
}
if (!pipelineId) {
return NextResponse.json(
{ error: 'pipeline_id or generation_request_id with source_pipeline_id required' },
{ status: 400 }
);
}
const { data: item } = await db
.from('creative_pipeline')
.select('id, notes, caption, format')
.eq('id', pipelineId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (!item) {
return NextResponse.json({ error: 'Pipeline piece not found' }, { status: 404 });
}
const assetUrl =
typeof body.asset_url === 'string'
? body.asset_url
: typeof body.stories_asset_url === 'string'
? body.stories_asset_url
: null;
let coverPath: string | null = null;
if (assetUrl) {
if (assetUrl.startsWith('http')) {
coverPath = await downloadToStorage({
tenantId,
pipelineId,
url: assetUrl,
filename: 'cover-ai',
});
// Fallback: store URL so UI can still show something via <img src>
if (!coverPath) coverPath = assetUrl;
} else {
coverPath = assetUrl;
}
}
const slideUrls = Array.isArray(body.slide_urls)
? body.slide_urls.filter((u): u is string => typeof u === 'string')
: [];
const slidePaths: string[] = [];
for (let i = 0; i < slideUrls.length; i++) {
const u = slideUrls[i];
if (u.startsWith('http')) {
const p = await downloadToStorage({
tenantId,
pipelineId,
url: u,
filename: `slide-${i + 1}-ai`,
});
slidePaths.push(p || u);
} else {
slidePaths.push(u);
}
}
const caption =
typeof body.caption === 'string'
? body.caption
: [body.hook, body.support, body.primary_text]
.filter((x) => typeof x === 'string' && x.trim())
.join('\n\n') || item.caption;
const status =
body.status === 'revision_agencia' || body.status === 'borrador'
? body.status
: coverPath
? 'revision_agencia'
: 'borrador';
const cleanNotes = stripGenerationRequestMarker(item.notes);
const grMarker = grId || parseGenerationRequestIdFromNotes(item.notes);
const notes = [
cleanNotes,
grMarker ? `[[generation_request:${grMarker}]]` : null,
coverPath ? 'Generado: Claude + Freepik + Canva' : null,
]
.filter(Boolean)
.join('\n');
const patch: Record<string, unknown> = {
notes,
updated_at: new Date().toISOString(),
status,
};
if (coverPath) {
patch.cover_path = coverPath;
patch.cover_mime = coverPath.includes('.png')
? 'image/png'
: coverPath.includes('.webp')
? 'image/webp'
: 'image/jpeg';
}
if (caption) patch.caption = caption;
if (slidePaths.length) patch.slide_paths = slidePaths;
const { data: updated, error } = await db
.from('creative_pipeline')
.update(patch)
.eq('id', pipelineId)
.eq('tenant_id', tenantId)
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (grId) {
const mark =
body.mark_request === 'fallida'
? 'fallida'
: body.error
? 'fallida'
: 'completada';
await db
.from('generation_requests')
.update({
status: mark,
completed_at: new Date().toISOString(),
error: typeof body.error === 'string' ? body.error : null,
result: {
pipeline_id: pipelineId,
cover_path: coverPath,
notes: 'rrss write-back',
},
})
.eq('id', grId)
.eq('tenant_id', tenantId);
}
return NextResponse.json({ ok: true, item: updated });
}

View File

@@ -0,0 +1,222 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// 1. Obtener la creatividad (la fatigada u objetivo, permitiendo null/none para peticiones manuales)
let creative: any = null;
if (id && id !== 'null' && id !== 'none' && id !== 'undefined') {
const { data: cData } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
creative = cData || null;
}
// 2. Obtener métricas de la creatividad si existe
let metrics: any[] = [];
if (creative?.meta_ad_id) {
const { data: mData } = await adminClient
.from('creative_metrics_daily')
.select('*')
.eq('tenant_id', tenantId)
.eq('meta_ad_id', creative.meta_ad_id)
.order('date', { ascending: true });
metrics = mData || [];
}
// 3. Reglas de fatiga del cliente
const { data: rules } = await adminClient
.from('fatigue_rules')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle();
// 4. Obtener todas las creatividades y métricas para top_creatives y rendimiento por ángulo
const [{ data: allCreatives }, { data: allMetrics }, { data: recentRejections }] =
await Promise.all([
adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url, status')
.eq('tenant_id', tenantId),
adminClient
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results')
.eq('tenant_id', tenantId),
adminClient
.from('approvals')
.select('creative_id, actor_role, comment, created_at')
.eq('tenant_id', tenantId)
.eq('decision', 'rechazada')
.order('created_at', { ascending: false })
.limit(5),
]);
const creativeMap = new Map<string, any>();
for (const c of allCreatives || []) {
creativeMap.set(c.id, c);
}
const creativePerformance = new Map<
string,
{ impressions: number; clicks: number; spend: number; results: number }
>();
for (const row of allMetrics || []) {
const current = creativePerformance.get(row.creative_id) || {
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
};
current.impressions += Number(row.impressions) || 0;
current.clicks += Number(row.clicks) || 0;
current.spend += Number(row.spend) || 0;
current.results += Number(row.results) || 0;
creativePerformance.set(row.creative_id, current);
}
const rankedIds = Array.from(creativePerformance.entries())
.filter(([_, stats]) => stats.impressions > 100)
.map(([creative_id, stats]) => ({
creative_id,
ctr: (stats.clicks / stats.impressions) * 100,
impressions: stats.impressions,
clicks: stats.clicks,
spend: stats.spend,
results: stats.results,
}))
.sort((a, b) => b.ctr - a.ctr)
.slice(0, 3);
let topCreatives: any[] = [];
if (rankedIds.length > 0) {
topCreatives = rankedIds
.map((r) => {
const row = creativeMap.get(r.creative_id);
if (!row) return null;
return {
...row,
historical_ctr: Number(r.ctr.toFixed(3)),
impressions: r.impressions,
clicks: r.clicks,
};
})
.filter(Boolean);
}
if (topCreatives.length === 0) {
topCreatives = (allCreatives || [])
.filter((c) => Boolean(c.angle))
.slice(0, 3);
}
// 4b. Rendimiento agregado por ángulo creativo (Improvement #7)
const angleAgg = new Map<
string,
{
angle: string;
creatives_count: number;
fatigued_count: number;
impressions: number;
clicks: number;
spend: number;
results: number;
}
>();
for (const c of allCreatives || []) {
const angleKey = (c.angle || '').trim();
if (!angleKey) continue;
const entry = angleAgg.get(angleKey) || {
angle: angleKey,
creatives_count: 0,
fatigued_count: 0,
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
};
entry.creatives_count += 1;
if (c.status === 'fatigada') entry.fatigued_count += 1;
const perf = creativePerformance.get(c.id);
if (perf) {
entry.impressions += perf.impressions;
entry.clicks += perf.clicks;
entry.spend += perf.spend;
entry.results += perf.results;
}
angleAgg.set(angleKey, entry);
}
const anglePerformance = Array.from(angleAgg.values())
.map((a) => ({
...a,
ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0,
cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null,
spend: Number(a.spend.toFixed(2)),
}))
.sort((a, b) => b.ctr - a.ctr);
// 4c. Últimos rechazos con feedback (Improvement #3)
const rejectedFeedback = (recentRejections || []).map((rej: any) => {
const c = creativeMap.get(rej.creative_id);
return {
creative_id: rej.creative_id,
rejected_by: rej.actor_role,
comment: rej.comment,
hook: c?.hook || null,
angle: c?.angle || null,
headline: c?.headline || null,
created_at: rej.created_at,
};
});
// 5. Obtener datos del cliente
const { data: tenant } = await adminClient
.from('tenants')
.select('id, name, slug, drive_folder_id, meta_ad_account_id, campaign_audience, campaign_products')
.eq('id', tenantId)
.maybeSingle();
const contextData = {
source_creative: creative,
creative,
metrics,
rules: rules || null,
top_creatives: topCreatives,
top_historical_creatives: topCreatives,
angle_performance: anglePerformance,
rejected_feedback: rejectedFeedback,
client: {
id: tenantId,
name: tenant?.name || null,
slug: tenant?.slug || null,
drive_folder_id: tenant?.drive_folder_id || null,
campaign_audience: (tenant as any)?.campaign_audience || {},
campaign_products: (tenant as any)?.campaign_products || {},
},
};
return NextResponse.json({
data: contextData,
client_id: tenantId,
tenant_id: tenantId,
...contextData,
});
}

View File

@@ -0,0 +1,194 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
const { searchParams } = new URL(req.url);
const include = searchParams.get('include');
const { data: creative, error: creativeErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (creativeErr || !creative) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
if (include === 'client') {
const { data: client } = await adminClient
.from('tenants')
.select('id, name, slug, meta_ad_account_id, meta_page_id, meta_ig_user_id, default_landing_url, meta_publish_status, slack_channel_id, canva_templates, drive_folder_id')
.eq('id', tenantId)
.maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
let canvaTemplates = (client as any)?.canva_templates || {};
if (
client?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
return NextResponse.json({
data: {
creative,
client: {
id: client?.id,
name: client?.name,
slug: client?.slug,
meta_ad_account_id: client?.meta_ad_account_id,
meta_page_id: client?.meta_page_id,
meta_ig_user_id: client?.meta_ig_user_id,
default_landing_url: client?.default_landing_url,
meta_publish_status: client?.meta_publish_status || 'PAUSED',
slack_channel_id: client?.slack_channel_id,
canva_templates: canvaTemplates,
drive_folder_id: (client as any)?.drive_folder_id || null,
},
},
});
}
return NextResponse.json({ data: creative });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { id } = await params;
const auth = await authenticateIntegrationRequest(req, { requireTenant: true, parsedBody: body });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// Obtener creatividad previa
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (getErr || !creative) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
const prevStatus = creative.status;
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
if (body.meta_ad_id !== undefined) updates.meta_ad_id = body.meta_ad_id;
if (body.status !== undefined) updates.status = body.status;
if (body.adset_id !== undefined) updates.adset_id = body.adset_id;
if (body.campaign_name !== undefined) updates.campaign_name = body.campaign_name;
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
const existingMeta =
creative.meta && typeof creative.meta === 'object' ? creative.meta : {};
const preservedRawUrl =
(existingMeta as any).raw_image_url ||
(!creative.asset_final && creative.asset_url ? creative.asset_url : undefined);
const placementMeta: Record<string, string> = {};
if (typeof body.stories_asset_url === 'string' && body.stories_asset_url) {
placementMeta.stories_asset_url = body.stories_asset_url;
}
if (typeof body.landscape_asset_url === 'string' && body.landscape_asset_url) {
placementMeta.landscape_asset_url = body.landscape_asset_url;
}
if (body.meta !== undefined && typeof body.meta === 'object' && body.meta !== null) {
updates.meta = {
...existingMeta,
...(preservedRawUrl ? { raw_image_url: preservedRawUrl } : {}),
...body.meta,
...placementMeta,
};
} else if (preservedRawUrl && body.asset_final) {
updates.meta = {
...existingMeta,
raw_image_url: preservedRawUrl,
...placementMeta,
};
} else if (Object.keys(placementMeta).length > 0) {
updates.meta = {
...existingMeta,
...placementMeta,
};
}
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update(updates)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single();
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
// Si pasa a publicada, cambia meta_ad_id o se sube asset_final, registrar en creative_events
if (updates.status === 'publicada' || updates.meta_ad_id || updates.asset_final) {
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: id,
event_type: updates.status === 'publicada' ? 'published' : updates.asset_final ? 'asset_final_composed' : 'creative_updated',
actor: auth.caller || 'n8n',
from_status: prevStatus,
to_status: updates.status || prevStatus,
payload: {
meta_ad_id: updates.meta_ad_id || creative.meta_ad_id,
adset_id: updates.adset_id,
asset_final: updates.asset_final,
meta: updates.meta,
},
visibility: updates.status === 'publicada' ? 'client' : 'agency',
});
}
return NextResponse.json({
ok: true,
data: updated,
...updated,
});
}

View File

@@ -0,0 +1,278 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdWebhookAspectFields } from '@/lib/creatives-aspect-ratios';
import { normalizeMetaAdCopyInput } from '@/lib/meta-ad-copy';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, { requireTenant: true, parsedBody: body });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// Acepta array o un único objeto
const items = Array.isArray(body)
? body
: Array.isArray(body.creatives)
? body.creatives
: [body];
if (!items.length) {
return NextResponse.json({ error: 'No creatives provided' }, { status: 400 });
}
// Si viene generation_request_id, averiguar parent_id (source_creative_id del request)
let defaultParentId: string | null = null;
const genRequestId = body.generation_request_id || items[0]?.generation_request_id;
if (genRequestId) {
const { data: genReq } = await adminClient
.from('generation_requests')
.select('source_creative_id')
.eq('id', genRequestId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (genReq) {
defaultParentId = genReq.source_creative_id;
}
}
const toInsert = items.map((c: any) => {
const copyNorm = normalizeMetaAdCopyInput({
primary_text: c.primary_text,
primary_texts: c.primary_texts,
headline: c.headline,
headlines: c.headlines,
description: c.description,
hook: c.hook,
support: c.support,
cta: c.cta,
meta: c.meta || {},
});
const meta = { ...copyNorm.meta };
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
if (c.composition_notes_by_aspect) {
meta.composition_notes_by_aspect = c.composition_notes_by_aspect;
}
if (c.stories_asset_url) meta.stories_asset_url = c.stories_asset_url;
if (c.landscape_asset_url) meta.landscape_asset_url = c.landscape_asset_url;
if (c.asset_url && !c.asset_final && !meta.raw_image_url) {
meta.raw_image_url = c.asset_url;
}
return {
tenant_id: tenantId,
campaign_name: c.campaign_name || null,
adset_id: c.adset_id || null,
meta_ad_id: c.meta_ad_id || null,
format: c.format || 'estatico',
angle: c.angle || null,
hook: copyNorm.hook || c.hook || null,
primary_text: copyNorm.primary_text,
headline: copyNorm.headline,
description: copyNorm.description,
cta: c.cta || null,
asset_url: c.asset_url || null,
thumbnail_url: c.thumbnail_url || null,
asset_final: Boolean(c.asset_final ?? false),
status: 'borrador', // nacen siempre en borrador
visibility: 'agency', // nacen siempre en agency
created_by: c.created_by || 'claude', // creadas por Claude
parent_id: c.parent_id || defaultParentId,
generation_request_id: c.generation_request_id || genRequestId || null,
meta,
};
});
const { data: inserted, error } = await adminClient
.from('creatives')
.insert(toInsert)
.select('*');
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const { data: tenant } = await adminClient
.from('tenants')
.select('id, name, slug, drive_folder_id, canva_templates')
.eq('id', tenantId)
.maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
let canvaTemplates = tenant?.canva_templates || {};
if (
tenant?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const clientInfo = {
id: tenant?.id || tenantId,
name: tenant?.name || null,
slug: tenant?.slug || 'bps',
drive_folder_id: tenant?.drive_folder_id || null,
canva_templates: canvaTemplates,
};
// Registrar cada creación en creative_events con actor 'claude' y disparar creative.created si created_by='claude' y asset_final=false
for (const c of inserted || []) {
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: c.id,
event_type: 'creative_generated',
actor: 'claude',
to_status: 'borrador',
payload: {
angle: c.angle,
format: c.format,
generation_request_id: c.generation_request_id,
parent_id: c.parent_id,
},
visibility: 'agency',
});
if (c.created_by === 'claude' && c.asset_final === false) {
dispatchN8nWebhookAsync('creative.created', {
creative: {
...c,
client_id: c.tenant_id,
meta: c.meta || {},
client: clientInfo,
},
...staticAdWebhookAspectFields(c.format),
});
}
}
return NextResponse.json({
ok: true,
count: inserted?.length || 0,
creatives: inserted,
});
}
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: false });
if (!auth.ok) {
return auth.errorResponse!;
}
const searchParams = req.nextUrl.searchParams;
const tenantId = searchParams.get('tenant_id') || searchParams.get('client_id');
const status = searchParams.get('status');
const assetFinal = searchParams.get('asset_final');
const createdBy = searchParams.get('created_by');
const include = searchParams.get('include');
const limitParam = searchParams.get('limit');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
let query = adminClient
.from('creatives')
.select('*, tenant:tenants(id, name, slug, canva_templates, drive_folder_id, creatives_enabled)');
if (tenantId) {
query = query.eq('tenant_id', tenantId);
}
if (status) {
query = query.eq('status', status);
}
if (assetFinal === 'true') {
query = query.eq('asset_final', true);
} else if (assetFinal === 'false') {
query = query.eq('asset_final', false);
}
if (createdBy) {
query = query.eq('created_by', createdBy);
}
query = query.order('created_at', { ascending: true });
if (limitParam) {
const parsedLimit = Number(limitParam);
if (!isNaN(parsedLimit) && parsedLimit > 0) {
query = query.limit(parsedLimit);
}
}
const { data: rows, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
const enriched = (rows || [])
.filter((row: any) => {
if (tenantId) return true;
return row.tenant?.creatives_enabled !== false;
})
.map((row: any) => {
const { tenant, ...rest } = row;
let canvaTemplates = tenant?.canva_templates || {};
if (
tenant?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const item: Record<string, any> = {
...rest,
client_id: rest.tenant_id,
meta: rest.meta || {},
};
if (include === 'client') {
item.client = tenant
? {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
canva_templates: canvaTemplates,
drive_folder_id: tenant.drive_folder_id || null,
}
: null;
}
return item;
});
return NextResponse.json({
ok: true,
count: enriched.length,
data: enriched,
creatives: enriched,
});
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, { requireTenant: true, parsedBody: body });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const eventType = body.event_type || 'notice';
const actor = auth.caller || body.actor || 'system';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
const { data: event, error } = await adminClient
.from('creative_events')
.insert({
tenant_id: tenantId,
creative_id: body.creative_id || null,
event_type: eventType,
actor,
from_status: body.from_status || null,
to_status: body.to_status || null,
payload: body.payload || {},
visibility: body.visibility || 'agency',
})
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
ok: true,
event,
});
}

View File

@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// 1. Obtener reglas de fatiga del cliente
const { data: rules, error: rulesErr } = await adminClient
.from('fatigue_rules')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle();
if (rulesErr) {
return NextResponse.json({ error: rulesErr.message }, { status: 500 });
}
const lookbackDays = Number(rules?.lookback_days || 7);
const minHistory = lookbackDays * 2;
// Query param history_days (por defecto 28, mínimo lookback_days * 2)
const url = new URL(req.url);
const rawHistory = url.searchParams.get('history_days');
let historyDays = rawHistory ? parseInt(rawHistory, 10) : 28;
if (isNaN(historyDays) || historyDays < minHistory) {
historyDays = Math.max(28, minHistory);
}
// 2. Calcular fecha de inicio según history_days
const sinceDate = new Date();
sinceDate.setDate(sinceDate.getDate() - historyDays);
const sinceStr = sinceDate.toISOString().split('T')[0];
// 3. Buscar creatividades actualmente publicadas para este cliente
const { data: publishedCreatives, error: creativesErr } = await adminClient
.from('creatives')
.select('id, tenant_id, campaign_name, adset_id, meta_ad_id, format, angle, hook, primary_text, headline, cta, asset_url, thumbnail_url, status, visibility, created_at')
.eq('tenant_id', tenantId)
.eq('status', 'publicada');
if (creativesErr) {
return NextResponse.json({ error: creativesErr.message }, { status: 500 });
}
const metaIds = (publishedCreatives || [])
.map((c: any) => c.meta_ad_id)
.filter(Boolean);
let metricsMap = new Map<string, any[]>();
if (metaIds.length > 0) {
const { data: metrics, error: metricsErr } = await adminClient
.from('creative_metrics_daily')
.select('date, impressions, reach, clicks, ctr, frequency, spend, results, cpa, meta_ad_id')
.eq('tenant_id', tenantId)
.in('meta_ad_id', metaIds)
.gte('date', sinceStr)
.order('date', { ascending: true });
if (metricsErr) {
return NextResponse.json({ error: metricsErr.message }, { status: 500 });
}
(metrics || []).forEach((m: any) => {
const list = metricsMap.get(m.meta_ad_id) || [];
list.push({
date: m.date,
impressions: Number(m.impressions || 0),
reach: Number(m.reach || 0),
clicks: Number(m.clicks || 0),
ctr: Number(m.ctr || 0),
frequency: Number(m.frequency || 0),
spend: Number(m.spend || 0),
results: Number(m.results || 0),
cpa: m.cpa !== null && m.cpa !== undefined ? Number(m.cpa) : null,
});
metricsMap.set(m.meta_ad_id, list);
});
}
const creatives = (publishedCreatives || []).map((c: any) => ({
id: c.id,
meta_ad_id: c.meta_ad_id,
hook: c.hook,
angle: c.angle,
campaign_name: c.campaign_name,
metrics: metricsMap.get(c.meta_ad_id) || [],
}));
const dataPayload = {
rules: rules || null,
creatives,
};
return NextResponse.json({
data: dataPayload,
client_id: tenantId,
tenant_id: tenantId,
lookback_days: lookbackDays,
history_days: historyDays,
rules: rules || null,
creatives,
candidates: creatives,
});
}

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