Compare commits

..

223 Commits

Author SHA1 Message Date
4Ventures
0b3f80d790 fix: aplicar layout Estilo/Brief a Zona de marca tras rebase
Nav lateral numerada + panel editable como creatividades; Auto-rellenar;
parse brandbook robusto para que app.4ventures.es muestre el kit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:36:41 +02:00
4Ventures
a75f3ae30c perf: creatividades warehouse-first + aviso OAuth Ads Production
Carga métricas desde ad_daily al instante (mode=fast) y enriquece previews en vivo; la UI de Agencia avisa si falta scope Ads o consent Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:35:45 +02:00
4Ventures
939c4a3131 fix: Meta creatividades nunca fallan — retries, ventanas cortas y fallback silencioso
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 02:06:28 +02:00
4Ventures
9270584934 fix: Meta Ads muestra clics todos y clics enlace (y ambos CPC)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:51:45 +02:00
4Ventures
ed2afff556 fix: CPL creatividades con céntimos
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:33:39 +02:00
4Ventures
c83efa492d fix: Ads CPC con céntimos + clics enlace Meta; gráficas barras/línea con ejes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 01:28:59 +02:00
4Ventures
5e8fd66e35 fix: Meta creatividades — 5 titulares, 5 textos y descripción
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:50:56 +02:00
4Ventures
331c19e873 fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales
OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:46:24 +02:00
4Ventures
6d8dfaa79c fix: Ads Meta conv/CPL = Clientes potenciales (sin sumar acciones solapadas)
Las creatividades inflaban ~3× al sumar lead + lead_grouped + registration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:32:51 +02:00
4Ventures
990915a051 fix: Superhuman sync solo agencia — mensaje claro y botón oculto a cliente
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:05:17 +02:00
4Ventures
c0707ffc1b fix: Social — solo posts IG y barras de evolución visibles con delta
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 17:57:56 +02:00
4Ventures
87c887294d fix: Social FB posts sin likes/comments.summary (evita error #10 Meta)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 15:15:07 +02:00
4Ventures
aa41594e1e fix: Social — métricas IG total_value, posts en formato original y tamaño
Rellena views/perfil/clics/interacciones con metric_type=total_value y series día a día; carruseles/vídeos/reels con aspect ratio y px originales.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:31:12 +02:00
4Ventures
530b5ac5ae fix: Social IG/FB con métricas Meta vigentes y creatividades Ad Tracker
Quita copy "Paid ads en Ads", migra Page Insights a page_media_view/page_follows y resuelve creatividades Meta a nivel anuncio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:19:58 +02:00
4Ventures
4a9c2139f7 fix: KPIs leen ad_daily desde ene 2025 para YoY completo
El lookback fijo de 420 días cortaba el histórico; ahora cubre desde 2025-01-01.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 13:02:40 +02:00
4Ventures
83e2b59e83 fix: MoM mes-en-curso al mes previo real y pipelines CRM históricos
El filtro Online se aplica por rango de fechas; backfill de pipelines para que agosto/YoY no queden en 4 ventas. MoM de «este mes» usa los mismos días del mes anterior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:46:08 +02:00
4Ventures
5235c88c13 fix: escapar '>' en copy de KPIs para no romper el build JSX
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:20:33 +02:00
4Ventures
062c43325d fix: ventas KPI = Online Clientify (89) y atribución por fuente real
Excluye Málaga del cohort; enriquece source desde lead/Clientify para que el cruce ads vs CRM deje de volcar todo en sin-atribuir.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:19:57 +02:00
4Ventures
48fb31d247 fix: alinear KPIs BPS al milímetro con Ads Manager y Clientify Online
Meta solo cuenta registration leads; Google guarda fracciones y redondea al sumar; ventas CRM usan pipeline Online con importe > 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 12:10:09 +02:00
4Ventures
4c8042aa23 fix: KPIs — ocultar WoW >7d, YoY con histórico completo, ventas en nº
Pagina ad_daily (~420d) para que YoY no quede vacío; Ventas muestra
conteo de deals (importe Clientify en hint/columna Importe).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 11:05:21 +02:00
4Ventures
dff9c46747 fix: excluir scripts del typecheck de Next para desbloquear el build
El deploy fallaba tipando scripts/sync_* (dotenv). También tipa el
fallback de select leads en KPIs/CRM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 10:41:52 +02:00
4Ventures
cf27eba74d feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 10:23:36 +02:00
4Ventures
dd50251de2 feat: editor enriquecido en notas de Seguimiento
Barra tipo Docs (negrita, color, resaltado, listas, enlaces, tamaño) al crear notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:35:42 +02:00
4Ventures
9f30aa6820 fix: narrowing de posts en Social API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:31:36 +02:00
4Ventures
b0d19e4da5 fix: tipado Social al mezclar posts FB/IG
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:31:02 +02:00
4Ventures
5e5fc87710 feat: Social con gráficas diarias por periodo y token Meta 4V2030
Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:30:44 +02:00
4Ventures
224f060e9d fix: Social FB — Page token + aviso si el Meta token no tiene pages_*
El token de ads no sirve para /posts; intentamos Page Access Token y explicamos los permisos necesarios.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:26:22 +02:00
4Ventures
04ea6de08a fix: notas Seguimiento Semanal con HTML completo, enlaces y formato
Exporta el Google Doc en HTML, parte por fecha y guarda todo el bloque (listas, negritas, links) sin truncar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:20:31 +02:00
4Ventures
751673c341 fix: vídeos en Social y notas Superhuman por día desde Drive
Los docs de seguimiento son Google Docs embebidos: se exportan, se parten por fecha y se acumulan. Social renderiza VIDEO/REELS y trae vídeos de página.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:09:49 +02:00
4Ventures
06024cf473 feat: auditoría SEO real con histórico y avisos
Crawl técnico (HTTPS/robots/sitemap/on-page) + tabla seo_audits; UI con nota, checks y alertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:55:58 +02:00
4Ventures
d896041bed fix: menú SEO realista — paneles distintos y sin stubs Semrush vacíos
Recorta la suite a secciones con GSC/GA4 (overview, auditoría checklist, rankings con histórico, tendencia, páginas, keywords, insights, contenido). Competencia/backlinks quedan como “herramienta externa”. Ya no se reutiliza el mismo dashboard en ítems distintos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:47:24 +02:00
4Ventures
4b6f7f1896 feat: Ads KPIs completos, Social feed+analytics, Orgánico por sección
Meta/Google con las mismas métricas que Resumen y solo campañas/ads con actividad en el periodo; Social con insights FB/IG y preview de posts (likes/comentarios/shares); Orgánico con paneles distintos por ítem del menú y prioridades/notas solo en visión general.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:30:17 +02:00
4Ventures
f4dfc50cc9 feat: calendario bajo Social, SEO/GEO con datos live, Drive = 03-Clientes
Calendario editorial anidado en Social; Orgánico con queries/páginas GSC y landings GA4 + dashboard GEO; Biblioteca enlazada a la carpeta real del cliente bajo 03-Clientes (no carpetas homónimas); previews Ads solo en Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:12:07 +02:00
4Ventures
3425364b07 feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico
Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 15:45:30 +02:00
4Ventures
eb1622c88c fix: import PageHeader desde ui en SocialDashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 13:45:58 +02:00
4Ventures
ba05626fed feat: menú Ads + Social; Drive por defecto en activos
Renombra Ad Tracker a Ads, añade módulo Social (perfiles RRSS)
y abre Biblioteca en pestaña Drive. Aclara histórico GSC en Orgánico.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 13:44:56 +02:00
4Ventures
7ed6e641cb feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en fichas
Bootstrap unificado post-OAuth + botón Emparejar todo. Meta discover
también intenta FB/IG pages con el token system user (API 4V2030).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:41:51 +02:00
4Ventures
99895fe918 feat: conectar GA4/GSC/Drive desde la ficha (verificar + sync real)
Al guardar ID o URL en el cliente se valida con OAuth de agencia;
Drive acepta URL o folder id; Orgánico se rellena si hay acceso.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:27:15 +02:00
4Ventures
81ba5df37d fix: ficha no finge sync al guardar IDs; normaliza URL de Drive
«Conectar» en la ficha solo guarda la referencia; last_sync queda
para syncs reales. Drive acepta URL completa → folder id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:22:41 +02:00
4Ventures
5501f45a67 fix: guardar OAuth Google con upsert real (PATCH vacío no insertaba)
El callback creía haber guardado el token y fallaba al emparejar Drive
con «no conectado». Upsert on_conflict=provider + match no bloquea OAuth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:17:46 +02:00
4Ventures
5d04a79de7 fix: una sola conexión Google en Agencia (quitar botón duplicado)
El panel orgánico ya no ofrece Conectar: solo emparejar/actualizar
sobre el OAuth del panel 1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:13:28 +02:00
4Ventures
d137b5796a feat: Orgánico siempre al día (auto-sync al abrir + cron diario)
El botón Sync 28d pasa a «Actualizar ahora»; la vista refresca sola
si faltan datos y hay endpoint /api/cron/organic-sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:10:02 +02:00
4Ventures
aded4c3514 feat: OAuth Google unificado (Drive+GA4+GSC) y sync a Orgánico
Conectar marketing@ con scopes Analytics/GSC; descubrir propiedades,
emparejar fichas y sincronizar organic_daily 28d.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 12:04:05 +02:00
4Ventures
81ede58330 feat: suite Orgánico tipo Semrush (SEO + GEO por secciones)
Navegación por visión general, competencia, prompts, percepción,
auditoría, keywords, backlinks y prioridades accionables por cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:25:32 +02:00
4Ventures
5c90bcf940 feat: vista Orgánico con SEO (GA4/GSC) y GEO (visibilidad IA)
Nueva sección con métricas Search Console/GA4, cimientos SEO y
Share of Model por motor (ChatGPT/Gemini/AI Overviews).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:18:26 +02:00
4Ventures
dd0e867108 feat: ficha por cliente en portafolio (conexiones, accesos, logo, módulos)
Cada tenant tiene una ficha con plataformas, invitaciones por email,
logo de marca y toggles de módulos visibles en el portal cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:51:21 +02:00
4Ventures
21051afda9 fix: cohort CAC/ROAS desde deals Clientify y más contraste en dashboards
Los totales de Métricas usaban solo ad_daily (cohort a 0). Ahora suman
deals won; selectores y tipografía ganan contraste sobre fondo blanco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:40:12 +02:00
4Ventures
8fda2996b6 feat: rango de fechas personalizado y datos hasta ayer (salvo Hoy)
Selector libre en Ad Tracker/Seguimiento; presets y KPIs cierran en ayer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:24:25 +02:00
4Ventures
068faa3d25 fix: tema claro blanco, logo 4Ventures y menú original
Fondo blanco en la app; menú con labels/iconos originales; logo en sidebar y login.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:53:36 +02:00
4Ventures
6d1602bd68 chore: quitar import unused en calendario
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:44:09 +02:00
4Ventures
bf9a504c40 feat: interfaz clara unificada en Centro de Mando
Shell con nav agrupada y activa, PageHeader común, tipografía y tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:32:35 +02:00
4Ventures
8c11291396 fix: paginar asset_group_asset de Google para no perder vídeos/imágenes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:40:39 +02:00
4Ventures
8091498508 feat: zoom de creatividades con assets verticales Meta y PMax completo
Preview 4:5 + textos en card; modal con todas las imágenes, vídeos y copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:38:02 +02:00
4Ventures
d05df8c371 fix: previews Meta vía proxy y creatividades Google PMax/RSA
Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:25:48 +02:00
4Ventures
86225a701e feat: previews de creatividades en Ad Tracker (estilo Brava)
Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:16:14 +02:00
4Ventures
be45df300b feat: pestaña CRM con % a cliente por canal y cruce ads vs Clientify
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:33:41 +02:00
4Ventures
f436276c3e feat: endpoint webhook Clientify para sync en tiempo real
URL pública por tenant (?tenant=slug) para contactos y oportunidades;
middleware permite POST sin sesión.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 15:40:41 +02:00
4Ventures
76c26c2e69 fix: KPIs abre en 7d como Ad Tracker por defecto
Misma ventana inicial (hoy-6…hoy) para que los totales coincidan al abrir ambas pantallas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 13:01:46 +02:00
4Ventures
fb313fc38f fix: alinear KPIs con Ad Tracker por días calendario
KPIs filtraba por inicio de semana (omitía días de Sep en la semana
del 31-ago); ahora Mes/7d/30d suman ad_daily día a día como Ad Tracker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 12:38:45 +02:00
4Ventures
fba025da76 fix: mostrar Google Ads desde ad_daily cuando kpi_weekly está vacío
kpi_weekly es una MV de solo lectura sin canales paid; canal/seguimiento
ahora hacen fallback a ad_daily. Bump Google Ads API a v21 en sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 12:13:41 +02:00
4Ventures
a1a00840d5 feat: sync Meta metrics, objetivos ppto/consecución y simulador por tenant
Rellena ad_daily desde Meta, KPIs con fallback a daily, objetivos de presupuesto vs consecución, y simulador sin TikTok anclado a métricas del cliente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 10:59:15 +02:00
4Ventures
0d286dc66c feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas
Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 09:33:22 +02:00
4Ventures
a7704567bd feat: preview de placement en pipeline de creatividades
Cada pieza muestra mock Meta/Google Ads o RRSS (feed, Stories, LinkedIn) como el Ad Tracker, con copy, media y canal editables.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 03:36:28 +02:00
4Ventures
b4ee23874e feat: RRSS hub estilo Miia + Ad Tracker por cliente
Replica agua-miia/rrss (hub, archivo, octubre 2026 con 27 piezas) y añade Ad Tracker por tenant al estilo Brava (periodos, Meta+Google, CPL/clics, campañas).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:55:14 +02:00
4Ventures
a1a3c26c30 feat: dual agency/client approval on calendar and assets
Private RRSS calendar with unified + per-network tabs, comments, and
double-check OK. Assets get review drawer with preview/comments. KPIs
gain date presets, North Star strip, channel matrix and per-channel dash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 02:38:44 +02:00
4Ventures
5460d5bd04 fix: exclude P&L and HR pages from Superhuman client sync
Keeps agency-only ops out of the client-facing seguimiento timeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 23:01:30 +02:00
4Ventures
982a9e2954 chore: Dokploy helper to merge Superhuman Docs API key
Keeps existing portal env vars and triggers deploy after merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:41:58 +02:00
4Ventures
64156383ca feat: interactive seguimiento timeline and channel evolution
Sync Superhuman Docs seguimiento/KPI pages into the portal (never passwords)
and show clients a dynamic timeline plus per-channel kpi_weekly charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:34:21 +02:00
4Ventures
6607a02bff feat: paste Meta token after App Review; Google-only discover
While Meta review is pending, agency can match Google Ads alone and
paste the system user token later without a redeploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:11:03 +02:00
4Ventures
c13001c69d feat: auto-discover Meta and Google Ads accounts for tenants
Agency panel lists accessible ad accounts and matches them to
clients by name, same pattern as Drive folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 19:24:11 +02:00
4Ventures
eb00256bea feat: add Instagram, Facebook, TikTok and LinkedIn slots
Organic social channels per client in the agency hub, separate from
paid Meta Ads.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:58:29 +02:00
4Ventures
670679d09c feat: add WordPress, PrestaShop and Shopify connection slots
Per-client CMS/ecommerce channels in the agency hub with URL/API
placeholders; live sync can come later.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:50:25 +02:00
4Ventures
c4c527072f feat: agency control panel for per-client connections
Matrix view of Meta/Google/Drive/Clientify, follow/archive clients,
and per-channel ON/OFF with account IDs in the client hub.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 18:47:29 +02:00
4Ventures
1eb02dac4d feat: Google Drive agency sync with auto folder match
Connect marketing@ once, match Drive folders to tenants by name,
list Drive in Activos, and mirror portal uploads to the linked folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 16:27:54 +02:00
4Ventures
31ca77558e fix: keep Activos as file library only
Agent documents (calendar/copy/audit) belong in Calendario/ops, not the asset upload surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:41:52 +02:00
4Ventures
ce9498e63c feat: upload any validated file to the asset library
Activos now supports multi-file/folder upload to private Storage
(PDF, Excel, CSV, images, ZIP, etc.) with per-tenant folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:26:33 +02:00
4Ventures
8182402d96 feat: metrics dashboard with WoW/MoM/YoY and creatives pipeline
Rebuild KPIs as a real dashboard (period deltas, tenant zoom), clarify
objetivos/calendario/activos sources, and turn creatives into a status
pipeline with delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 13:45:41 +02:00
4Ventures
791976a3d8 feat: agency portfolio with per-client integrations hub
Admin home lists tenants, alerts, and connection health; header
selector enables superadmin context; Meta/GA4/GSC/GTM/CRM/email
slots live per tenant (manual status until OAuth).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 01:31:47 +02:00
4Ventures
9d1609c88b docs: note JWT rotation ready but blocked on supabase compose
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:47:08 +02:00
4Ventures
5d448c0e71 ops: apply Traefik office allowlist for git and n8n UIs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:20:25 +02:00
4Ventures
73b3693eeb docs: record agent_runs RLS fix and Traefik IP candidate
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:16:44 +02:00
4Ventures
8315a8098f fix: stop baking Supabase anon key into the Docker image
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:07:04 +02:00
4Ventures
aa89df86a1 chore: ignore dokploy shots and add chat/traefik ops smokes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 18:02:55 +02:00
4Ventures
3c741fa457 docs: refresh operator runbook and creatives copy after prod cutover
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 17:46:43 +02:00
4Ventures
997ccf66d7 fix: unblock production Docker build (chat query + middleware types)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 13:31:16 +02:00
ec642b057a Merge pull request 'Harden auth + tenant security + creatives scaffold' (#1) from harden/auth-tenant-security into main 2026-09-21 10:22:54 +00:00
350 changed files with 91407 additions and 1353 deletions

View File

@@ -8,3 +8,25 @@ ANTHROPIC_API_KEY=replace-with-anthropic-key
OPS_ALERT_EMAIL=
CRON_SECRET=
N8N_WEBHOOK_BASE=https://n8n-agencia.4ventures.es
# Google Drive (agency marketing@) — OAuth Web client
NEXT_PUBLIC_APP_URL=https://app.4ventures.es
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
# Optional override; default: {APP_URL}/api/admin/drive/callback
# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
# Meta Ads discovery (long-lived / system user token)
META_ACCESS_TOKEN=
# Google Ads discovery (MCC via marketing@)
GOOGLE_ADS_DEVELOPER_TOKEN=
GOOGLE_ADS_REFRESH_TOKEN=
# Optional: reuse GOOGLE_OAUTH_CLIENT_* or set dedicated:
# GOOGLE_ADS_CLIENT_ID=
# GOOGLE_ADS_CLIENT_SECRET=
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
# Superhuman Docs (optional)
# SUPERHUMAN_DOCS_API_KEY=

36
.gitignore vendored
View File

@@ -13,6 +13,40 @@ dist/
.env
.env.*
!.env.example
.ops/
# local ops noise
scripts/dokploy-shots/
# one-shot cutover / debug (kept locally; not for main)
scripts/dokploy_cleanup_and_sql.py
scripts/dokploy_configure_deploy_portal.py
scripts/dokploy_create_and_deploy_portal.py
scripts/dokploy_cutover_portal.py
scripts/dokploy_deploy_portal_now.py
scripts/dokploy_env_reveal_save.py
scripts/dokploy_finalize_portal.py
scripts/dokploy_find_app_host.py
scripts/dokploy_find_supabase.py
scripts/dokploy_inspect_env.py
scripts/dokploy_inspect_portal.py
scripts/dokploy_inventory_domains.py
scripts/dokploy_redeploy_portal.py
scripts/dokploy_retarget_traefik.py
scripts/dokploy_run_creatives_sql.py
scripts/dokploy_set_env_and_ops.py
scripts/dokploy_sql_terminal_b64.py
scripts/dokploy_sql_via_terminal.py
scripts/dokploy_stop_orphan.py
scripts/dokploy_traefik_cm.py
scripts/dokploy_traefik_expand.py
scripts/dokploy_traefik_insert.py
scripts/dokploy_traefik_only.py
scripts/dokploy_traefik_unlock.py
scripts/dokploy_view_deploy_error.py
scripts/dokploy_collect_supabase_env.py
scripts/dokploy_find_jwt_secret.py
scripts/dokploy_prepare_key_rotation.py
tsconfig.tsbuildinfo
# debug / OS
npm-debug.log*
@@ -26,3 +60,5 @@ yarn-error.log*
*.local
coverage/
.turbo/
.ops/gitea-deploy/

View File

@@ -12,8 +12,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.4ventures.es
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NzIzMTgzMTAsImV4cCI6MTg5MzQ1NjAwMCwicm9sZSI6ImFub24iLCJpc3MiOiJzdXBhYmFzZSJ9.UWNn1leZNljna9Qwi9U5RtGqsh5_SnEoiPO8HkQIgi8
# NEXT_PUBLIC_* must come from Dokploy "Create Environment File" (.env) or build-args.
# Do not bake secrets into the image.
RUN npm run build
FROM base AS runner

View File

@@ -30,6 +30,7 @@ Health: `GET /api/health`
|------|-----|
| `/login` | Supabase Auth |
| `/dashboard/kpis` | KPIs |
| `/dashboard/seguimiento` | Timeline Superhuman + evolución canales |
| `/dashboard/estrategia` | Targets + simulador |
| `/dashboard/activos` | Docs aprobados (RLS) |
| `/dashboard/creatives` | Creatividades (en curso) |

View File

@@ -5,11 +5,21 @@
El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs reales**
(anon + service_role) y passwords de demo.
1. En Supabase Studio → Settings → API: **regenerar** `anon` y `service_role`.
2. Actualizar secrets en Dokploy / env del contenedor `app` y de n8n.
3. Cambiar passwords de `admin@…` y clientes demo; invalidar sesiones.
4. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs.
5. Confirmar que `.env.example` solo tiene placeholders (ya corregido en este repo).
### Rotación (checklist — hacerlo en ventana corta)
1. Supabase Studio → Settings → API → **regenerar** `anon` y `service_role`
**o** (self-hosted) cambiar `JWT_SECRET` / `GOTRUE_JWT_SECRET` y emitir JWTs nuevos.
2. Dokploy → client-portal Environment: pegar anon + service_role nuevos → Save → Deploy.
3. n8n (credenciales Supabase / HTTP headers): actualizar API keys.
4. Local: actualizar `.env.local` (gitignored); no tocar `.env.example` (placeholders).
5. Invalidar sesiones: Auth → Users → sign-out / cambiar password `marketing@4ventures.es` si hubo leak de demo.
6. Smoke: `curl /api/health` → `serviceRoleConfigured true`; `python3 scripts/smoke_operator_login.py`.
7. Rotar `ANTHROPIC_API_KEY` si estuvo en chats/docs (opcional, independiente).
**Estado 2026-09-21:** claves nuevas **preparadas** en `.ops/supabase_rotated.env` (gitignored).
**Aplicación bloqueada:** el stack vivo (`ventures-web-supabase-gm0nnn-*`) no tiene compose editable en Dokploy (`4ventures-infra` está en `error` / `composeFile` vacío) y SSH al VPS (`187.77.170.176:22`) hace timeout. Sin recrear contenedores con el nuevo `JWT_SECRET`, los JWT filtrados **siguen válidos**.
**Desbloqueo:** acceso host (SSH/VPN) o restaurar el compose Supabase en Dokploy → sustituir env → recrear servicios auth/kong/rest/studio → Deploy client-portal.
## Superficies a restringir (amenaza)
@@ -20,6 +30,9 @@ El PDF “Agencia 2030” y el antiguo `.env.example` del repo contenían **JWTs
| `supabase` Studio | Solo red interna / SSO admin |
| `app` | Público OK; APIs tras sesión (middleware) |
Patch Traefik **aplicado** 2026-09-21 (ver `.ops/HARDEN_SURFACES.md`).
Allowlist oficina: `90.161.248.78/32` — UI git/n8n; webhooks abiertos. Si el ISP cambia la IP, actualizar middleware `office-allowlist`.
## Controles en código (este repo)
- Middleware exige sesión en `/dashboard/*` y `/api/*` (excepto `/api/health`).

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

@@ -1,55 +1,91 @@
# Operador marketing@4ventures.es
La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`).
## Estado (2026-09-21)
## Alta / reset en Studio
- Usuario **creado** en Supabase Auth (`role=admin`, `tenant_id` = 4Ventures Central).
- Password local (gitignored): `.ops/marketing-credentials.txt`
- Login verificado en https://app.4ventures.es/login → `/dashboard/*` y `/dashboard/agencia`
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
- Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente)
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: **auto-bootstrap** (Drive + GA4 + GSC + Google Ads MCC → fichas + sync Orgánico). Repetir: panel 2 → Emparejar todo Google.
- Meta (API 4V2030 / system user): pegar token en Agencia → Descubrir ambos. Empareja Meta Ads + páginas Facebook/Instagram a fichas (si el token tiene `pages_show_list` / business).
- Cron diario (recomendado): env `CRON_SECRET` en Dokploy + schedule:
`curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync`
(actualiza últimos 7 días de todos los clientes con GA4/GSC conectados).
- Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente.
- Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar.
- Meta App Review: mientras esperas aprobación, usa **Solo Google Ads**. Cuando tengas el system user token, pégalo en Agencia (se guarda en `agency_oauth`, sin redeploy) → Descubrir ambos.
1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra.
2. **Authentication → Users → Add user**
- Email: `marketing@4ventures.es`
- Password: genera una fuerte (guárdala en el gestor de la agencia)
- Auto Confirm: sí
3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo:
## Login
1. Abre https://app.4ventures.es/login
2. Email: `marketing@4ventures.es`
3. Password: la de `.ops/marketing-credentials.txt` (o el gestor de la agencia)
## Reset / reprovision (si hace falta)
Studio (Kong) o Admin API con service role:
```json
{
"role": "admin",
"company": "4Ventures",
"slug": "4ventures-hub"
"slug": "4ventures-hub",
"tenant_id": "0b3c5fc0-6901-4caf-b26a-b6a0201d8541"
}
```
App metadata (preferible para rol):
```json
{
"role": "admin",
"tenant_id": "<uuid-del-tenant-hub-si-existe>"
}
```
4. Si usáis `provision_client`, también podéis:
```sql
select public.provision_client(
'4Ventures',
'4ventures-hub',
'marketing@4ventures.es',
'CONTRASEÑA_FUERTE_AQUI',
'admin'
);
```
5. Login en https://app.4ventures.es/login con ese email/password.
## Rotación de API keys (obligatorio)
## Rotación de API keys (recomendado)
Settings → API → Regenerate `anon` y `service_role`.
Actualizar en Dokploy env del contenedor `app` (y n8n si las usa).
Nunca volver a pegar keys en PDFs ni `.env.example`.
Actualizar en Dokploy → Web4Ventures → `client-portal` → Environment (API `application.update`).
Nunca pegar keys en PDFs ni `.env.example`.
## Pendiente ops
- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM
- OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico)
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
- **`CLIENTIFY_WEBHOOK_SECRET` obligatorio** — sin él el webhook responde 503
- **Cron diario (obligatorio en prod)** — ver sección Cron
## Cron diario (datos siempre frescos para vistas cliente)
En Dokploy → Environment del `client-portal`:
1. Define `CRON_SECRET` (string largo aleatorio).
2. Schedule diario ~06:00 UTC:
```bash
curl -sS -H "Authorization: Bearer $CRON_SECRET" \
"https://app.4ventures.es/api/cron/daily-sync?days=7"
```
Endpoints individuales (mismo secret):
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
- `/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).
## Verificación
- Rol `admin` → ve `/dashboard/agencia`
- Rol `client` → redirigido fuera de agencia
- Chat/simulate solo con sesión
- Health: `serviceRoleConfigured: true`
- Tras cron: Orgánico / Ads / Seguimiento con `last_sync_at` de hoy en ficha

View File

@@ -1,24 +1,41 @@
# Runbook — Centro de Mando 4Ventures
Operador: `marketing@4ventures.es`
Operador: `marketing@4ventures.es`
Credenciales locales (gitignored): `.ops/marketing-credentials.txt`
## Health
```bash
curl -sf https://app.4ventures.es/api/health
# expect: serviceRoleConfigured true
./scripts/smoke.sh https://app.4ventures.es
python3 scripts/smoke_operator_login.py
python3 scripts/smoke_chat_auth.py
```
## Deploy (Dokploy)
1. Push a `main` en Gitea `Marketing/4ventures-client-portal`.
2. Dokploy rebuild del servicio `app`.
3. Env: `NEXT_PUBLIC_SUPABASE_*`, `SUPABASE_SERVICE_ROLE_KEY`, `ANTHROPIC_API_KEY`.
4. Smoke: `/login` → KPIs → simulador → chat.
2. Dokploy → **Web4Ventures / production / client-portal** → Deploy
(id interno `web4ventures-clientportal-jntjhs`).
3. Environment (runtime + build via “Create Environment File”):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `ANTHROPIC_API_KEY` (opcional, LLM)
4. Traefik: `portal.yml` → `http://web4ventures-clientportal-jntjhs:3000`
5. Smoke: `/login` → KPIs → creatividades → chat.
## Alta cliente
## Portafolio agencia (admin)
1. Login `marketing@` → redirige a `/dashboard/agencia`.
2. Cabecera **Contexto**: selector de cliente (modo superadmin) o vista global.
3. Tarjetas de portafolio: salud de integraciones + **Entrar** / **Integraciones**.
4. Ficha `/dashboard/agencia/cliente/[id]`: Meta, Google Ads, GA4, GSC, GTM, Clientify, Klaviyo, Mailchimp (estado manual por ahora).
5. SQL: `sql/agency_portfolio.sql` (tablas `integrations` + `agency_alerts`).
OAuth live Meta/MCC/GA4 = siguiente fase; el esqueleto ya escala por tenant.
UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
## Atribución
@@ -28,6 +45,17 @@ UI `/dashboard/agencia` (roles admin/team) o RPC `provision_client`.
`POST https://n8n-agencia.4ventures.es/webhook/lead-inbound`
## Endurecer git / n8n
Aplicado 2026-09-21: IP allowlist `90.161.248.78/32` en UI de git + n8n; webhooks `/webhook` abiertos.
Detalle / revert: `.ops/HARDEN_SURFACES.md`. Re-aplicar: `python3 scripts/dokploy_apply_traefik_harden.py`.
`app.4ventures.es` y Dokploy **sin** allowlist.
## Rotación JWT Supabase
Claves nuevas en `.ops/supabase_rotated.env` (gitignored). Aplicar requiere recrear el stack Supabase (ver `SECURITY.md`).
## Incidentes
Ver `SECURITY.md`.

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

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

3239
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

BIN
public/logo-4ventures.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Preview · Seguimiento · Virginia Molet</title>
<style>
:root { --bg:#0B0F19; --card:#111827; --side:#0D121F; --line:#1F2937; --brand:#E58A3C; --text:#F3F4F6; --muted:#9CA3AF; }
* { box-sizing:border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; background:var(--bg); color:var(--text); }
.shell { display:flex; min-height:100vh; }
aside { width:240px; background:var(--side); border-right:1px solid #1f2937; padding:20px 14px; }
.logo { display:flex; gap:10px; align-items:center; padding:8px; border-bottom:1px solid #1f293780; margin-bottom:18px; }
.mark { width:36px; height:36px; border-radius:10px; background:linear-gradient(135deg,#E58A3C,#B35E1B); display:grid; place-items:center; font-weight:800; }
nav a { display:flex; gap:10px; align-items:center; color:#D1D5DB; text-decoration:none; padding:10px 12px; border-radius:10px; font-size:14px; margin-bottom:4px; }
nav a.active { background:#1f293799; color:#fff; }
main { flex:1; padding:28px 36px; max-width:980px; }
h1 { margin:0; font-size:28px; letter-spacing:-.02em; }
.sub { color:var(--muted); font-size:14px; margin-top:6px; }
.badge { font-size:11px; color:#9CA3AF; border:1px solid #1f2937; background:#0d121f80; border-radius:8px; padding:8px 12px; }
.card { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:20px; margin-top:22px; }
.head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; }
.tabs { display:flex; gap:4px; background:#0003; border:1px solid #1f2937; border-radius:10px; padding:3px; }
.tabs span { font-size:12px; padding:6px 10px; border-radius:8px; color:#9CA3AF; }
.tabs span.on { background:#E58A3C33; color:#F0B27A; }
.chs { display:flex; gap:8px; margin:14px 0 8px; flex-wrap:wrap; }
.chs span { font-size:12px; border:1px solid #374151; border-radius:999px; padding:4px 10px; text-transform:capitalize; display:inline-flex; gap:6px; align-items:center; }
.chs i { width:8px; height:8px; border-radius:50%; display:inline-block; }
.preview-note { font-size:11px; color:#FBBF24; margin-top:8px; }
ol { list-style:none; margin:18px 0 0; padding:0 0 0 14px; border-left:1px solid #1f2937; }
li { position:relative; margin:0 0 14px 12px; }
.dot { position:absolute; left:-19px; top:18px; width:12px; height:12px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 4px var(--bg); }
article { background:var(--card); border:1px solid #1f2937; border-radius:14px; padding:14px 16px; }
.row { display:flex; justify-content:space-between; gap:10px; }
.pill { font-size:10px; text-transform:uppercase; letter-spacing:.06em; border:1px solid; border-radius:6px; padding:2px 7px; margin-right:8px; }
.when { font-size:11px; color:#6B7280; }
h3 { margin:8px 0 0; font-size:14px; }
article p { margin:8px 0 0; font-size:12px; color:#9CA3AF; line-height:1.45; }
article a { color:#F0B27A; font-size:11px; text-decoration:none; white-space:nowrap; }
.top { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
</style>
</head>
<body>
<div class="shell">
<aside>
<div class="logo"><div class="mark">4V</div><div><strong style="font-size:13px">4VENTURES</strong><div style="font-size:11px;color:#9CA3AF">Centro de Mando</div></div></div>
<nav>
<a href="#">Métricas</a>
<a class="active" href="#">● Seguimiento</a>
<a href="#">Objetivos del Mes</a>
<a href="#">Calendario Editorial</a>
<a href="#">Biblioteca de Activos</a>
</nav>
</aside>
<main>
<div class="top">
<div>
<h1>Seguimiento &amp; evolución</h1>
<p class="sub">Virginia Molet · timeline Superhuman + series de canal</p>
</div>
<div class="badge">Preview UI · datos reales de timeline</div>
</div>
<section class="card">
<div class="head">
<strong>Evolución por canal</strong>
<div class="tabs"><span class="on">Leads</span><span>Inversión</span><span>CPL</span></div>
</div>
<div class="chs">
<span><i style="background:#E58A3C"></i>meta</span>
<span><i style="background:#38BDF8"></i>google</span>
<span><i style="background:#34D399"></i>organic</span>
</div>
<svg viewBox="0 0 720 260" width="100%" style="max-width:100%">
<line x1="44" x2="704" y1="224" y2="224" stroke="#1F2937"/><text x="36" y="227" text-anchor="end" fill="#6B7280" font-size="10">0</text><line x1="44" x2="704" y1="172.0" y2="172.0" stroke="#1F2937"/><text x="36" y="175.0" text-anchor="end" fill="#6B7280" font-size="10">18</text><line x1="44" x2="704" y1="120.0" y2="120.0" stroke="#1F2937"/><text x="36" y="123.0" text-anchor="end" fill="#6B7280" font-size="10">36</text><line x1="44" x2="704" y1="68.0" y2="68.0" stroke="#1F2937"/><text x="36" y="71.0" text-anchor="end" fill="#6B7280" font-size="10">54</text><line x1="44" x2="704" y1="16" y2="16" stroke="#1F2937"/><text x="36" y="19" text-anchor="end" fill="#6B7280" font-size="10">72</text>
<path d="M 44.0 102.7 L 138.3 85.3 L 232.6 65.1 L 326.9 76.7 L 421.1 50.7 L 515.4 56.4 L 609.7 30.4 L 704.0 16.0" fill="none" stroke="#E58A3C" stroke-width="2.5"/><circle cx="44.0" cy="102.7" r="3.5" fill="#E58A3C"/><circle cx="138.3" cy="85.3" r="3.5" fill="#E58A3C"/><circle cx="232.6" cy="65.1" r="3.5" fill="#E58A3C"/><circle cx="326.9" cy="76.7" r="3.5" fill="#E58A3C"/><circle cx="421.1" cy="50.7" r="3.5" fill="#E58A3C"/><circle cx="515.4" cy="56.4" r="3.5" fill="#E58A3C"/><circle cx="609.7" cy="30.4" r="3.5" fill="#E58A3C"/><circle cx="704.0" cy="16.0" r="3.5" fill="#E58A3C"/><path d="M 44.0 189.3 L 138.3 183.6 L 232.6 192.2 L 326.9 177.8 L 421.1 172.0 L 515.4 180.7 L 609.7 166.2 L 704.0 160.4" fill="none" stroke="#38BDF8" stroke-width="2.5"/><circle cx="44.0" cy="189.3" r="3.5" fill="#38BDF8"/><circle cx="138.3" cy="183.6" r="3.5" fill="#38BDF8"/><circle cx="232.6" cy="192.2" r="3.5" fill="#38BDF8"/><circle cx="326.9" cy="177.8" r="3.5" fill="#38BDF8"/><circle cx="421.1" cy="172.0" r="3.5" fill="#38BDF8"/><circle cx="515.4" cy="180.7" r="3.5" fill="#38BDF8"/><circle cx="609.7" cy="166.2" r="3.5" fill="#38BDF8"/><circle cx="704.0" cy="160.4" r="3.5" fill="#38BDF8"/><path d="M 44.0 200.9 L 138.3 198.0 L 232.6 203.8 L 326.9 195.1 L 421.1 192.2 L 515.4 198.0 L 609.7 189.3 L 704.0 186.4" fill="none" stroke="#34D399" stroke-width="2.5"/><circle cx="44.0" cy="200.9" r="3.5" fill="#34D399"/><circle cx="138.3" cy="198.0" r="3.5" fill="#34D399"/><circle cx="232.6" cy="203.8" r="3.5" fill="#34D399"/><circle cx="326.9" cy="195.1" r="3.5" fill="#34D399"/><circle cx="421.1" cy="192.2" r="3.5" fill="#34D399"/><circle cx="515.4" cy="198.0" r="3.5" fill="#34D399"/><circle cx="609.7" cy="189.3" r="3.5" fill="#34D399"/><circle cx="704.0" cy="186.4" r="3.5" fill="#34D399"/>
<text x="44.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-06</text><text x="138.3" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-13</text><text x="232.6" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-20</text><text x="326.9" y="250" text-anchor="middle" fill="#6B7280" font-size="9">07-27</text><text x="421.1" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-03</text><text x="515.4" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-10</text><text x="609.7" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-17</text><text x="704.0" y="250" text-anchor="middle" fill="#6B7280" font-size="9">08-24</text>
</svg>
<p class="preview-note">La gráfica usa series de ejemplo (Virginia aún no tiene kpi_weekly). El timeline inferior sí es sync real de Superhuman.</p>
</section>
<section style="margin-top:28px">
<strong style="font-size:15px">Timeline reunión a reunión <span style="color:#6B7280;font-weight:400">(12)</span></strong>
<ol>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">18 Aug 2026</span>
<h3>Gravitae: Meta Ads/Google ADs</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suvQLvnL" target="_blank">Superhuman ↗</a>
</div>
<p>- **[Landing Page del Practitioner en Coaching Ejecutivo](https://gravitaeacademy.com/es/diploma-en-coaching-ejecutivo/)**
📊 KPIs a vigilar las primeras 2 semanas
## Copy of T</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">01 Jul 2026</span>
<h3>wbf_mad_sice.pdf</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suJVY_uk" target="_blank">Superhuman ↗</a>
</div>
<p>Abrir en Superhuman para el detalle completo.</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">01 Jul 2026</span>
<h3>Info emails Open Event 9/7/26</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_su8PImAd" target="_blank">Superhuman ↗</a>
</div>
<p>&gt; Añadimos a Carl Lewis que tiene mucho nombre
También quería compartir una oportunidad que puede ser de interés para quienes trabajáis en liderazgo, gestión de equipos o transfo</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">25 Jun 2026</span>
<h3>Email Open Event 9 julio.pdf</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suQBkgXM" target="_blank">Superhuman ↗</a>
</div>
<p>Abrir en Superhuman para el detalle completo.</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">18 Jun 2026</span>
<h3>Email 1</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suSUWITB" target="_blank">Superhuman ↗</a>
</div>
<p>1. EMAIL A TODOS LOS QUE SE HAN REGISTRADO AL EVENTO Y NO HAN IDO
TITULO: Ponte al día con lo que te perdiste en el I Salón Internacional de Coaching Ejecutivo
Hola NOMBRE,</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">08 Jun 2026</span>
<h3>WhatApp</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_suguIU-Q" target="_blank">Superhuman ↗</a>
</div>
<p>- Hola nombre. Quedan 4 días, te esperamos el viernes a las 9:30 en el saloninternacionacionalcoaching.com., por favor confirma tu asistencia abajo.
- Confirmar Asistencia
- N</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">24 Apr 2026</span>
<h3>Secuencia emails 240426</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sulZ9VOu" target="_blank">Superhuman ↗</a>
</div>
<p>Es una [secuencia de 6 emails](https://drive.google.com/drive/folders/1i7717lRLgpgU5Y6FZonbbrpWjS3_pD96?usp=sharing), el primero para hoy si es posible y os ha dado tiempo a prepar</p>
</article>
</li>
<li>
<span class="dot"></span>
<article>
<div class="row">
<div>
<span class="pill" style="color:#34D399;border-color:#34D39955;background:#34D39922">Tema</span>
<span class="when">23 Apr 2026</span>
<h3>Web Salón V2</h3>
</div>
<a href="https://docs.superhuman.com/d/_dqt1ZA3XaMp/_sumI6wo-" target="_blank">Superhuman ↗</a>
</div>
<p>**Pestanas: Registro / Agenda / Organiza / Contacto**
### **AGENDA**
Te mando por aquí el texto para la agenda: primero poner un texto introductorio, luego los conferenciantes c</p>
</article>
</li>
</ol>
</section>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env npx tsx
/**
* Backfill deals.pipeline_id / pipeline_name / source / name from Clientify
* for one tenant so KPI Online filter + attribution work for MoM/YoY.
*
* TENANT_SLUG=bps npx tsx scripts/backfill_deal_pipelines.ts
*/
import { config } from 'dotenv';
config({ path: '.env.local' });
const CFY = 'https://api.clientify.com/v1';
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
function envKey(slug: string) {
const s = slug.toUpperCase().replace(/-/g, '_');
return (
process.env[`CLIENTIFY_API_KEY_${s}`] ||
process.env.CLIENTIFY_API_KEY ||
''
);
}
function isPaidAds(s: string) {
const x = s.toLowerCase();
return (
/^(meta|facebook|instagram|google|google ads|google-ads|google_ads)/.test(
x
) ||
x.includes('facebook') ||
x.includes('google ads')
);
}
function normalizeSource(s: string | null | undefined): string | null {
const x = (s || '').toLowerCase().trim();
if (!x) return null;
if (isPaidAds(x)) return x.includes('google') ? 'google' : 'meta';
if (x.includes('blue parrot') || x.includes('woocommerce') || x === 'ecommerce')
return 'ecommerce';
if (x.includes('recomend') || x.includes('amigo')) return 'referral';
if (
x.includes('nivel') ||
x.includes('prueba') ||
x.startsWith('test') ||
x === 'general' ||
x === 'organic' ||
x === 'unknown' ||
x === 'import' ||
x === 'manual'
)
return 'organic';
if (x.startsWith('integrations')) return 'integrations-api';
return x.slice(0, 80);
}
function inferSource(d: {
deal_source?: string;
contact_source?: string;
tags?: string[];
name?: string;
}): string | null {
const fromDeal = d.deal_source || d.contact_source || '';
const tags = (d.tags || []).join(' ').toLowerCase();
if (isPaidAds(fromDeal)) return normalizeSource(fromDeal);
if (/facebook|instagram|\bmeta\b|\bfb\b/.test(tags)) return 'meta';
if (/google/.test(tags)) return 'google';
if (fromDeal) return normalizeSource(fromDeal);
if (/woocommerce|ecommerce/.test(tags) || /^order\s+\d+/i.test(d.name || ''))
return 'ecommerce';
return null;
}
async function main() {
const slug = (process.env.TENANT_SLUG || 'bps').trim();
const token = envKey(slug);
if (!token || !url || !key) throw new Error('missing env');
const tenants = await (
await fetch(
`${url}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,name`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
const tid = tenants[0]?.id as string;
if (!tid) throw new Error(`tenant ${slug} not found`);
console.log('tenant', tenants[0].name, tid);
// Load warehouse won deals needing backfill (or all)
const wh: {
id: string;
crm_deal_id: string;
lead_id: string | null;
pipeline_id: string | null;
}[] = [];
for (let offset = 0; offset < 100000; offset += 1000) {
const chunk = await (
await fetch(
`${url}/rest/v1/deals?tenant_id=eq.${tid}&stage=eq.won&select=id,crm_deal_id,lead_id,pipeline_id&order=won_at.desc.nullslast&limit=1000&offset=${offset}`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
if (!Array.isArray(chunk) || !chunk.length) break;
wh.push(...chunk);
if (chunk.length < 1000) break;
}
const byCrm = new Map(wh.map((d) => [d.crm_deal_id.replace(/^clientify:/, ''), d]));
console.log('warehouse won', wh.length);
// Lead sources for attribution priority
const leadSrc = new Map<string, string>();
const leadIds = [...new Set(wh.map((d) => d.lead_id).filter(Boolean))] as string[];
for (let i = 0; i < leadIds.length; i += 200) {
const ids = leadIds.slice(i, i + 200);
const rows = await (
await fetch(
`${url}/rest/v1/leads?id=in.(${ids.join(',')})&select=id,source`,
{ headers: { apikey: key, Authorization: `Bearer ${key}` } }
)
).json();
if (Array.isArray(rows)) {
for (const r of rows) if (r.source) leadSrc.set(r.id, r.source);
}
}
let page = 1;
let patched = 0;
let seen = 0;
while (page <= 200) {
const j = await (
await fetch(
`${CFY}/deals/?page_size=100&page=${page}&status=3&ordering=-actual_closed_date`,
{ headers: { Authorization: `Token ${token}` } }
)
).json();
const results = j.results || [];
if (!results.length) break;
for (const d of results) {
seen++;
const did = String(d.id);
const row = byCrm.get(did);
if (!row) continue;
const pipeUrl = String(d.pipeline || '');
const pipeline_id = pipeUrl.includes('/pipelines/')
? pipeUrl.replace(/\/$/, '').split('/').pop() || null
: null;
const pipeline_name = d.pipeline_desc || null;
const fromLead = row.lead_id ? leadSrc.get(row.lead_id) : null;
let source: string | null = null;
if (fromLead && isPaidAds(fromLead)) source = normalizeSource(fromLead);
else {
const inferred = inferSource(d);
if (inferred && isPaidAds(inferred)) source = inferred;
else if (fromLead) source = normalizeSource(fromLead);
else source = inferred;
}
if (
!source &&
(pipeline_id === '77547' || /online/i.test(pipeline_name || ''))
) {
source = 'ecommerce';
}
const r = await fetch(`${url}/rest/v1/deals?id=eq.${row.id}`, {
method: 'PATCH',
headers: {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
pipeline_id,
pipeline_name,
source,
name: d.name || null,
}),
});
if (r.ok) patched++;
}
const oldest = (results[results.length - 1].actual_closed_date || '').slice(
0,
10
);
console.log(`page ${page} seen=${seen} patched=${patched} oldest=${oldest}`);
if (!j.next) break;
// Stop if we've gone past all warehouse deals' likely range
if (oldest && oldest < '2024-01-01') break;
page++;
}
console.log('DONE', { seen, patched, warehouse: wh.length });
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,61 @@
/**
* Backfill pipeline covers/slides → client Drive folder tree.
* npx tsx scripts/backfill_pipeline_drive.ts [tenantSlug]
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import { backfillPipelineItemToDrive } from '../src/lib/pipeline-drive';
async function main() {
const slugFilter = process.argv[2]?.trim();
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) throw new Error('service role missing');
const tRes = await fetch(`${base}/rest/v1/tenants?select=id,name,slug`, {
headers,
cache: 'no-store',
});
let tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
if (slugFilter) {
tenants = tenants.filter(
(t) => t.slug === slugFilter || t.name.toLowerCase().includes(slugFilter.toLowerCase())
);
}
if (!tenants.length) throw new Error('no tenants');
for (const t of tenants) {
const pRes = await fetch(
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
t.id
)}&cover_path=not.is.null&select=id,title,format,caption,notes,scheduled_at,created_at,cover_path,slide_paths&order=created_at.desc&limit=50`,
{ headers, cache: 'no-store' }
);
const pieces = pRes.ok ? await pRes.json() : [];
console.log(`\n=== ${t.name} (${pieces.length} con portada) ===`);
for (const item of pieces) {
const r = await backfillPipelineItemToDrive({
tenantId: t.id,
clientLabel: t.slug || t.name,
item,
});
console.log(
item.title,
r.ok ? 'OK' : 'PARTIAL',
r.results.map((x) => `${x.slot}:${x.detail}`).join(' | ')
);
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,38 @@
[
{ "id": "oct-01", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "3 false friends que te delatan" },
{ "id": "oct-02", "format": "story", "month": "2026-10", "category": "aprende inglés", "title": "Palabra del día: deadline" },
{ "id": "oct-03", "format": "reel", "month": "2026-10", "category": "speaking", "title": "Preséntate en inglés en 30 segundos" },
{ "id": "oct-04", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Cursos intensivos: certifica tu nivel" },
{ "id": "oct-05", "format": "story", "month": "2026-10", "category": "test de nivel", "title": "¿No sabes por dónde empezar?" },
{ "id": "oct-06", "format": "carrusel", "month": "2026-10", "category": "cursos y certificaciones", "title": "Aptis, Cambridge o IELTS: ¿cuál te conviene?" },
{ "id": "oct-07", "format": "reel", "month": "2026-10", "category": "aprende inglés", "title": "Make o do: el truco para no fallar" },
{ "id": "oct-08", "format": "story", "month": "2026-10", "category": "comunidad", "title": "¿Qué idioma quieres aprender?" },
{ "id": "oct-09", "format": "carrusel", "month": "2026-10", "category": "inglés para el trabajo", "title": "3 frases para sonar más natural en una reunión" },
{ "id": "oct-10", "format": "reel", "month": "2026-10", "category": "comunidad", "title": "Un día en la academia" },
{ "id": "oct-11", "format": "post", "month": "2026-10", "category": "cursos y certificaciones", "title": "Grupos de máximo 8 alumnos" },
{ "id": "oct-12", "format": "carrusel", "month": "2026-10", "category": "aprende inglés", "title": "Phrasal verbs con «get» que usarás cada día" },
{ "id": "nov-01", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "Friendsgiving: el inglés de Acción de Gracias con Friends" },
{ "id": "nov-02", "format": "reel", "month": "2026-11", "category": "inglés para el día a día", "title": "Black Friday: 5 palabras que verás en todas las tiendas" },
{ "id": "nov-03", "format": "story", "month": "2026-11", "category": "comunidad", "title": "¿Deal o bargain? Tú decides" },
{ "id": "nov-04", "format": "carrusel", "month": "2026-11", "category": "inglés con series", "title": "El inglés de Stranger Things" },
{ "id": "nov-05", "format": "post", "month": "2026-11", "category": "cursos y certificaciones", "title": "Certifica tu nivel antes de que acabe el año" },
{ "id": "nov-06", "format": "reel", "month": "2026-11", "category": "speaking", "title": "Thanks, cheers, much appreciated: cómo dar las gracias en inglés" },
{ "id": "nov-07", "format": "story", "month": "2026-11", "category": "aprende inglés", "title": "Palabra del día: leftovers" },
{ "id": "dic-01", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "El inglés de Solo en casa" },
{ "id": "dic-02", "format": "story", "month": "2026-12", "category": "aprende inglés", "title": "Palabra del día: cosy" },
{ "id": "dic-03", "format": "reel", "month": "2026-12", "category": "speaking", "title": "Felicita la Navidad en inglés sin decir solo Merry Christmas" },
{ "id": "dic-04", "format": "carrusel", "month": "2026-12", "category": "inglés con películas", "title": "Love Actually: frases para practicar tu inglés" },
{ "id": "dic-05", "format": "post", "month": "2026-12", "category": "cursos y certificaciones", "title": "Estas Navidades, regala un idioma" },
{ "id": "dic-06", "format": "reel", "month": "2026-12", "category": "aprende inglés", "title": "Christmas vocabulary: mistletoe, stocking, Boxing Day" },
{ "id": "dic-07", "format": "story", "month": "2026-12", "category": "comunidad", "title": "¿Tu propósito de 2027 es aprender un idioma?" },
{ "id": "ene27-01", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "New Year's resolutions: di tus propósitos en inglés" },
{ "id": "ene27-02", "format": "story", "month": "2027-01", "category": "test de nivel", "title": "Empieza el año sabiendo tu nivel" },
{ "id": "ene27-03", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Take up, give up, keep up: 3 phrasal verbs para tus propósitos" },
{ "id": "ene27-04", "format": "post", "month": "2027-01", "category": "cursos y certificaciones", "title": "Nuevo año, nuevo idioma" },
{ "id": "ene27-05", "format": "carrusel", "month": "2027-01", "category": "aprende inglés", "title": "Blue Monday: vocabulario para los días grises" },
{ "id": "ene27-06", "format": "reel", "month": "2027-01", "category": "aprende inglés", "title": "Winter vocabulary: freezing, chilly, to bundle up" },
{ "id": "ene27-07", "format": "story", "month": "2027-01", "category": "aprende inglés", "title": "Palabra del día: chilly" }
]

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Apply sql/agency_portfolio.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "agency_portfolio.sql"
SHOT = ROOT / "scripts" / "dokploy-shots"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1500, "height": 1100})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(500)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1200)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1200)
# db image usually has bash
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(3000)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 900) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/agency.b64 /tmp/agency.sql; echo READY", 1500)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/agency.b64", 500)
run(
"base64 -d /tmp/agency.b64 > /tmp/agency.sql && wc -c /tmp/agency.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agency.sql; echo EXIT:$?",
10000,
)
run(
"psql -U postgres -d postgres -c \"select count(*) as integrations from public.integrations;\" ; echo DONE",
4000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
page.screenshot(path=str(SHOT / "agency_sql.png"), full_page=True)
browser.close()
ok = "EXIT:0" in text
print("EXIT0", ok, flush=True)
for ln in text.splitlines():
if re.search(r"EXIT:|ERROR|integrations|DONE|READY", ln, re.I):
print(">", ln.strip()[:200], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

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,127 @@
#!/usr/bin/env python3
"""Apply sql/asset_files.sql + ensure storage bucket via Dokploy / API."""
from __future__ import annotations
import base64
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "asset_files.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/af.b64 /tmp/af.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/af.b64", 400)
run(
"base64 -d /tmp/af.b64 > /tmp/af.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/af.sql; echo EXIT:$?",
9000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text
print("SQL_OK", ok, flush=True)
# Create bucket via service role from .env.local
vals = {}
for ln in (ROOT / ".env.local").read_text().splitlines():
if "=" in ln and not ln.strip().startswith("#"):
k, v = ln.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
url = vals["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
key = vals["SUPABASE_SERVICE_ROLE_KEY"]
req = urllib.request.Request(
url + "/storage/v1/bucket",
data=json.dumps(
{
"id": "tenant-assets",
"name": "tenant-assets",
"public": False,
"file_size_limit": 83886080,
}
).encode(),
headers={
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
print("BUCKET", r.status, flush=True)
except urllib.error.HTTPError as e:
body = e.read().decode()[:200]
print("BUCKET", e.code, body, flush=True)
if e.code not in (409, 400) and "exist" not in body.lower():
return 5 if not ok else 0
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Apply Traefik harden patches (office IP allowlist). Does NOT touch portal.yml / app / supabase."""
from __future__ import annotations
import re
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
ROOT = Path("/Users/4ventures/4ventures-client-portal")
PATCH = ROOT / ".ops" / "traefik-patches"
SHOT = ROOT / "scripts" / "dokploy-shots"
BASE = "https://dokploy.4ventures.es"
# (ui label in Traefik file list, local patch file)
FILES = [
("middleware-hardening.yml", "middleware-hardening.yml"),
("gitea.yml", "gitea.yml"),
("n8n-hardened.yml", "n8n-hardened.yml"),
("rutas-manuales.yml", "rutas-manuales.yml"),
]
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def set_monaco(page, val: str) -> str:
mode = page.evaluate(
"""(val) => {
const m = window.monaco;
if (m && m.editor) {
const eds = m.editor.getEditors();
if (eds && eds[0]) { eds[0].setValue(val); return 'monaco'; }
}
const cm = document.querySelector('.cm-content');
if (cm) {
cm.focus();
return 'cm';
}
return 'none';
}""",
val,
)
if mode == "cm":
page.locator(".cm-content").first.click(force=True)
page.keyboard.press("Meta+A")
page.keyboard.insert_text(val)
return mode
def probe(url: str) -> tuple[int, str]:
try:
req = urllib.request.Request(url, method="GET", headers={"User-Agent": "4v-harden-probe"})
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, (r.read(80) or b"").decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, (e.read(80) or b"").decode("utf-8", "replace")
except Exception as e:
return 0, type(e).__name__
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_DOKPLOY_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
for label, fname in FILES:
if not (PATCH / fname).is_file():
print("MISSING_PATCH", fname, flush=True)
return 3
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(900)
for label, fname in FILES:
yaml = (PATCH / fname).read_text(encoding="utf-8")
# find file in list
loc = page.get_by_text(re.compile(rf"^{re.escape(label)}$", re.I))
if not loc.count():
loc = page.locator(f"text={label}")
if not loc.count():
print("FILE_MISSING_IN_UI", label, flush=True)
page.screenshot(path=str(SHOT / f"harden_missing_{fname}.png"), full_page=True)
browser.close()
return 4
loc.first.dblclick()
page.wait_for_timeout(1200)
unlock = page.get_by_role("button", name=re.compile(r"^Unlock$", re.I))
if unlock.count():
unlock.first.click()
page.wait_for_timeout(800)
mode = set_monaco(page, yaml)
print("set", label, mode, "chars", len(yaml), flush=True)
upd = page.get_by_role("button", name=re.compile(r"^Update$", re.I))
if not upd.count():
upd = page.get_by_role("button", name=re.compile(r"Update|Save", re.I))
upd.first.click()
page.wait_for_timeout(2000)
page.screenshot(path=str(SHOT / f"harden_applied_{fname.replace('.','_')}.png"), full_page=True)
# back to file list
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(700)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(700)
browser.close()
time.sleep(3)
checks = [
("git", "https://git.4ventures.es/"),
("n8n_ui", "https://n8n.4ventures.es/"),
("n8n_agencia_ui", "https://n8n-agencia.4ventures.es/"),
("app", "https://app.4ventures.es/api/health"),
("dokploy", "https://dokploy.4ventures.es/"),
]
ok = True
for name, url in checks:
code, head = probe(url)
print(f"probe {name} {code} {head[:60]!r}", flush=True)
# From allowlisted IP: git/n8n should NOT be 403
if name in ("git", "n8n_ui", "n8n_agencia_ui") and code == 403:
ok = False
if name == "app" and code != 200:
ok = False
print("SUCCESS" if ok else "CHECK_FAILED", flush=True)
return 0 if ok else 5
if __name__ == "__main__":
raise SystemExit(main())

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,89 @@
#!/usr/bin/env python3
"""Dump Traefik gitea.yml + n8n-hardened.yml + middlewares for harden prep."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
OUT = Path("/Users/4ventures/4ventures-client-portal/.ops/traefik-dumps")
SHOT = Path("/Users/4ventures/4ventures-client-portal/scripts/dokploy-shots")
BASE = "https://dokploy.4ventures.es"
FILES = ("middlewares.yml", "middleware-hardening.yml", "gitea.yml", "n8n-hardened.yml")
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
OUT.mkdir(parents=True, exist_ok=True)
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/traefik", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.get_by_text(re.compile(r"^dynamic$", re.I)).first.click()
page.wait_for_timeout(800)
for name in FILES:
page.locator(f"text={name}").first.dblclick()
page.wait_for_timeout(1500)
# Unlock not needed for read; try extract from body line numbers area
body = page.inner_text("body")
# Heuristic: capture from 'http:' or 'http\n' yaml start
m = re.search(r"(?ms)^(http:|tcp:|udp:|tls:).*(?=Unlock|Skip YAML|Update\n|$)", body)
# Better: get cm content
text = page.evaluate(
"""() => {
const rows = document.querySelector('.cm-content');
return rows ? rows.innerText : '';
}"""
)
if not text or len(text) < 20:
# fallback strip UI chrome
lines = []
capture = False
for ln in body.splitlines():
if ln.strip() in ("http:", "tcp:", "tls:") or ln.startswith("http:"):
capture = True
if capture:
if ln.strip() in ("Unlock", "Update", "Skip YAML validation (for Go templating)"):
break
lines.append(ln)
text = "\n".join(lines)
(OUT / name).write_text(text + "\n", encoding="utf-8")
print("dumped", name, "chars", len(text), flush=True)
page.screenshot(path=str(SHOT / f"dump_{name.replace('.','_')}.png"), full_page=True)
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

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

215
scripts/dokploy_env_api.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Set client-portal env via Dokploy authenticated fetch (in-page), then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
HEALTH = "https://app.4ventures.es/api/health"
def dokploy_cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def load_env_text() -> str:
vals: dict[str, str] = {}
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
parts = []
for k in (
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
):
if not vals.get(k):
raise SystemExit(f"missing {k}")
parts.append(f"{k}={vals[k]}")
return "\n".join(parts) + "\n"
def main() -> int:
env_text = load_env_text()
email = dokploy_cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = dokploy_cred("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1500, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
# Discover API by probing common Dokploy endpoints from authenticated page
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
probe = page.evaluate(
"""async ({ appId, env }) => {
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env } },
{ path: '/api/application.update', body: { applicationId: appId, applicationStatus: 'done', env } },
{ path: '/api/trpc/application.update', body: { json: { applicationId: appId, env } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env } } } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env } } } },
];
for (const t of payloads) {
try {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 240) });
if (r.ok) return { ok: true, tries };
} catch (e) {
tries.push({ path: t.path, error: String(e) });
}
}
// Also GET one application to learn shape
try {
const r = await fetch('/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } })), { credentials: 'include' });
tries.push({ path: 'application.one', status: r.status, body: (await r.text()).slice(0, 500) });
} catch (e) {
tries.push({ path: 'application.one', error: String(e) });
}
return { ok: false, tries };
}""",
{"appId": APP_ID, "env": env_text},
)
print(json.dumps(probe, indent=2)[:4000], flush=True)
if not probe.get("ok"):
# UI-only: careful eye click inside Environment Settings card
page.goto(APP + "?tab=environment", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1200)
page.screenshot(path=str(SHOT / "env_api_fail.png"), full_page=True)
# Click ONLY the eye in the Environment Settings header row
clicked = page.evaluate(
"""() => {
const labels = [...document.querySelectorAll('*')].filter(el => el.childNodes.length && [...el.childNodes].some(n => n.nodeType===3 && n.textContent.trim()==='Environment Settings'));
for (const label of labels) {
let root = label;
for (let i=0;i<6;i++) {
root = root.parentElement;
if (!root) break;
const btns = [...root.querySelectorAll('button')].filter(b => b.querySelector('svg') && !b.innerText.trim());
if (btns.length) { btns[0].click(); return true; }
}
}
return false;
}"""
)
print("careful eye", clicked, flush=True)
page.wait_for_timeout(800)
page.screenshot(path=str(SHOT / "env_careful.png"), full_page=True)
# fill first textarea or contenteditable
filled = page.evaluate(
"""(val) => {
const tas = [...document.querySelectorAll('textarea')];
if (tas.length) {
const ta = tas[0];
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, val);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
return 'textarea';
}
const cm = document.querySelector('.cm-content');
if (cm) {
cm.setAttribute('contenteditable','true');
return 'cm-present';
}
return 'none';
}""",
env_text,
)
print("filled", filled, flush=True)
if filled == "cm-present":
page.locator(".cm-content").first.click(force=True)
page.keyboard.press("Meta+A")
page.keyboard.insert_text(env_text)
save = page.get_by_role("button", name=re.compile(r"^Save$", re.I))
if save.count():
save.first.click(force=True)
page.wait_for_timeout(2000)
print("saved ui", flush=True)
# Deploy regardless if API ok
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(700)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
page.get_by_role("tab", name=re.compile(r"^Deployments$", re.I)).first.click()
deadline = time.time() + 780
while time.time() < deadline:
page.reload(wait_until="networkidle")
body = page.inner_text("body")
m = re.search(r"1\.\s*(Error|Done|Running)", body)
status = m.group(1) if m else "?"
print("status", status, flush=True)
if status == "Done":
break
if status == "Error":
browser.close()
return 6
time.sleep(15)
browser.close()
for _ in range(30):
try:
with urllib.request.urlopen(HEALTH, timeout=12) as r:
body = r.read().decode()
print("health", body[:220], flush=True)
if '"serviceRoleConfigured":true' in body:
print("SUCCESS", flush=True)
return 0
except Exception as e:
print("wait", type(e).__name__, flush=True)
time.sleep(6)
print("PARTIAL", flush=True)
return 5
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Run RLS checklist query in supabase-db; print table/RLS summary."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
BASE = "https://dokploy.4ventures.es"
OUT = ROOT / ".ops" / "rls_inventory.txt"
QUERY = r"""
SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY 1;
"""
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
# compact to one line for psql -c
q = " ".join(QUERY.split())
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill("supabase-db")
page.wait_for_timeout(1000)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
page.wait_for_timeout(3000)
helper = page.locator("textarea.xterm-helper-textarea")
if helper.count():
helper.first.click()
cmd = f"psql -U postgres -d postgres -c \"{q}\" ; echo RLS_DONE"
page.keyboard.type(cmd, delay=2)
page.keyboard.press("Enter")
page.wait_for_timeout(6000)
page.screenshot(path=str(SHOT / "rls_term.png"), full_page=True)
text = page.evaluate(
"""() => {
const rows = document.querySelector('.xterm-rows');
return rows ? rows.innerText : '';
}"""
)
OUT.parent.mkdir(exist_ok=True)
OUT.write_text(text or "", encoding="utf-8")
print(text[-2500:] if text else "NO_TEXT", flush=True)
browser.close()
print("wrote", OUT, flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Merge CRON_SECRET into client-portal Dokploy env, then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = (
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
)
HEALTH = "https://app.4ventures.es/api/health"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
for p in (BPS / ".env", ROOT / ".env.local"):
if not p.is_file():
continue
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
cron = cred("CRON_SECRET")
if not cron:
print("missing CRON_SECRET locally", flush=True)
return 2
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 2
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, cronSecret }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
map['CRON_SECRET'] = cronSecret;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const keys = Object.keys(map);
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) });
if (r.ok) return { ok: true, keys, tries, hadCron: keys.includes('CRON_SECRET') };
}
return { ok: false, keys, tries, envLen: envText.length };
}""",
{"appId": APP_ID, "cronSecret": cron},
)
print(
json.dumps(
{
"ok": result.get("ok"),
"hadCron": result.get("hadCron"),
"keyCount": len(result.get("keys") or []),
"tries": result.get("tries"),
"step": result.get("step"),
"status": result.get("status"),
},
indent=2,
)[:2000],
flush=True,
)
if not result.get("ok"):
browser.close()
return 6
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(600)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
for i in range(30):
time.sleep(10)
try:
import urllib.request
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
except Exception as e:
code = str(e)
print(f"health_try{i}", code, flush=True)
if code == 200 and i >= 6:
# give container time to pick new env after first healthy
break
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Merge GOOGLE_ADS_LOGIN_CUSTOMER_ID into client-portal Dokploy env."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = (
f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l"
f"/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
)
KEY = "GOOGLE_ADS_LOGIN_CUSTOMER_ID"
VALUE = "5963150101"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
if not password:
print("missing DOKPLOY_PASSWORD", flush=True)
return 2
chromium = Path.home() / "Library/Caches/ms-playwright/chromium-1148/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
launch_kwargs = {"headless": True}
if chromium.is_file():
launch_kwargs["executable_path"] = str(chromium)
with sync_playwright() as p:
browser = p.chromium.launch(**launch_kwargs)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, key, value }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
const prev = map[key] || null;
map[key] = value;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 160) });
if (r.ok) {
return { ok: true, prev, set: value, keys: Object.keys(map).length, tries };
}
}
return { ok: false, step: 'update', prev, tries };
}""",
{"appId": APP_ID, "key": KEY, "value": VALUE},
)
browser.close()
print(result, flush=True)
return 0 if result.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Merge SUPERHUMAN_DOCS_API_KEY into client-portal Dokploy env, then Deploy."""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
BASE = "https://dokploy.4ventures.es"
APP_ID = "NZS797t9Rnq9Pj4mHiii3"
APP = f"{BASE}/dashboard/project/Z_4M-uDOH1_--itGSTh4l/environment/bGja70l_a7HQ1dxyXqSuf/services/application/{APP_ID}"
HEALTH = "https://app.4ventures.es/api/health"
def cred(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def local_key() -> str:
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if line.startswith("SUPERHUMAN_DOCS_API_KEY="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
p = ROOT / ".ops" / "superhuman-docs-api-key.txt"
if p.is_file():
return p.read_text(encoding="utf-8").strip()
return ""
def parse_env(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in (text or "").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
return out
def dump_env(vals: dict[str, str]) -> str:
return "\n".join(f"{k}={v}" for k, v in vals.items()) + "\n"
def main() -> int:
sh_key = local_key()
if not sh_key:
print("missing SUPERHUMAN_DOCS_API_KEY locally", flush=True)
return 2
email = cred("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = cred("DOKPLOY_PASSWORD")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(600)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(APP, wait_until="networkidle", timeout=120000)
page.wait_for_timeout(800)
result = page.evaluate(
"""async ({ appId, shKey }) => {
const oneUrl = '/api/trpc/application.one?batch=1&input=' + encodeURIComponent(JSON.stringify({ '0': { json: { applicationId: appId } } }));
const one = await fetch(oneUrl, { credentials: 'include' });
const oneText = await one.text();
if (!one.ok) return { ok: false, step: 'one', status: one.status, body: oneText.slice(0, 400) };
let envText = '';
try {
const j = JSON.parse(oneText);
const data = j[0]?.result?.data?.json || j[0]?.result?.data || j;
envText = data?.env || data?.environment || '';
if (typeof envText !== 'string') envText = '';
} catch (e) {
return { ok: false, step: 'parse', error: String(e), body: oneText.slice(0, 400) };
}
const lines = envText.split('\\n').filter(Boolean);
const map = {};
for (const line of lines) {
const i = line.indexOf('=');
if (i < 0) continue;
map[line.slice(0, i)] = line.slice(i + 1);
}
map['SUPERHUMAN_DOCS_API_KEY'] = shKey;
const merged = Object.keys(map).map(k => k + '=' + map[k]).join('\\n') + '\\n';
const keys = Object.keys(map);
const tries = [];
const payloads = [
{ path: '/api/application.update', body: { applicationId: appId, env: merged } },
{ path: '/api/trpc/application.update?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
{ path: '/api/trpc/application.saveEnvironment?batch=1', body: { '0': { json: { applicationId: appId, env: merged } } } },
];
for (const t of payloads) {
const r = await fetch(t.path, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(t.body),
});
const text = await r.text();
tries.push({ path: t.path, status: r.status, body: text.slice(0, 180) });
if (r.ok) return { ok: true, keys, tries, hadKey: keys.includes('SUPERHUMAN_DOCS_API_KEY') };
}
return { ok: false, keys, tries, envLen: envText.length };
}""",
{"appId": APP_ID, "shKey": sh_key},
)
print(
json.dumps(
{
"ok": result.get("ok"),
"hadKey": result.get("hadKey"),
"keyCount": len(result.get("keys") or []),
"tries": result.get("tries"),
"step": result.get("step"),
"status": result.get("status"),
},
indent=2,
)[:2000],
flush=True,
)
if not result.get("ok"):
browser.close()
return 6
# Deploy
page.goto(APP, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
page.get_by_role("tab", name=re.compile(r"^General$", re.I)).first.click()
page.wait_for_timeout(500)
page.get_by_role("button", name=re.compile(r"^Deploy$", re.I)).first.click()
page.wait_for_timeout(600)
conf = page.get_by_role("button", name=re.compile(r"^Confirm$", re.I))
if conf.count():
conf.first.click()
print("DEPLOY_CLICKED", flush=True)
for i in range(24):
time.sleep(10)
try:
import urllib.request
code = urllib.request.urlopen(HEALTH, timeout=12).getcode()
except Exception as e:
code = str(e)
print(f"health_try{i}", code, flush=True)
if code == 200:
break
browser.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Verify agent_runs RLS; apply sql/agent_runs_rls.sql only if still disabled."""
from __future__ import annotations
import base64
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SHOT = ROOT / "scripts/dokploy-shots"
OUT = ROOT / ".ops" / "rls_inventory.txt"
BASE = "https://dokploy.4ventures.es"
SQL_FILE = ROOT / "sql/agent_runs_rls.sql"
INVENTORY = (
"SELECT c.relname AS table_name, c.relrowsecurity AS rls_enabled "
"FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE n.nspname = 'public' AND c.relkind = 'r' ORDER BY 1;"
)
def env(key: str) -> str:
import os
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text(encoding="utf-8").splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def term_run(page, cmd: str, wait_ms: int = 4000) -> None:
helper = page.locator("textarea.xterm-helper-textarea")
if helper.count():
helper.first.click()
page.keyboard.type(cmd, delay=2)
page.keyboard.press("Enter")
page.wait_for_timeout(wait_ms)
def term_text(page) -> str:
return page.evaluate(
"""() => {
const rows = document.querySelector('.xterm-rows');
return rows ? rows.innerText : '';
}"""
)
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_DOKPLOY_PASSWORD", flush=True)
return 2
SHOT.mkdir(parents=True, exist_ok=True)
OUT.parent.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1200})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(700)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="networkidle", timeout=120000)
page.wait_for_timeout(1000)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1200)
page.keyboard.press("Escape")
page.wait_for_timeout(300)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(600)
page.get_by_text(re.compile(r"^Terminal$", re.I)).first.click()
page.wait_for_timeout(3500)
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV', 7000)
text = term_text(page)
OUT.write_text(text[-4000:], encoding="utf-8")
print("inventory_written", flush=True)
agent_on = bool(re.search(r"agent_runs\s+\|\s+t\b", text))
agent_off = bool(re.search(r"agent_runs\s+\|\s+f\b", text))
print("agent_runs_rls", "on" if agent_on else ("off" if agent_off else "unknown"), flush=True)
if agent_on:
print("OK_ALREADY", flush=True)
browser.close()
return 0
if not agent_off:
print("PARSE_FAIL — see .ops/rls_inventory.txt", flush=True)
page.screenshot(path=str(SHOT / "rls_parse_fail.png"), full_page=True)
browser.close()
return 4
# Apply SQL via base64 chunks
b64 = base64.b64encode(SQL_FILE.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 200] for i in range(0, len(b64), 200)]
term_run(page, "rm -f /tmp/agent_rls.b64 /tmp/agent_rls.sql; echo READY", 2000)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
term_run(page, f"printf '%s' '{ch}' {op} /tmp/agent_rls.b64", 800)
term_run(
page,
"base64 -d /tmp/agent_rls.b64 > /tmp/agent_rls.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/agent_rls.sql; "
"echo EXIT:$?",
8000,
)
term_run(page, f'psql -U postgres -d postgres -c "{INVENTORY}" ; echo RLS_INV2', 7000)
text2 = term_text(page)
OUT.write_text(text2[-4000:], encoding="utf-8")
page.screenshot(path=str(SHOT / "rls_after.png"), full_page=True)
ok = bool(re.search(r"agent_runs\s+\|\s+t\b", text2))
print("agent_runs_after", "on" if ok else "still_off", flush=True)
browser.close()
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

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,63 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const IDS = [
'1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s',
'1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg',
];
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
for (const id of IDS) {
const metaR = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const meta = (await metaR.json()) as {
id: string;
name: string;
mimeType: string;
};
console.log('META', meta);
const mime = meta.mimeType || '';
let buf: Buffer;
if (mime.includes('spreadsheet')) {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/csv')}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.csv`, buf);
} else if (mime.includes('document')) {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent('text/plain')}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.txt`, buf);
} else {
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}?alt=media&supportsAllDrives=true`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(await r.text());
buf = Buffer.from(await r.arrayBuffer());
writeFileSync(`/tmp/bps-cal/${id}.bin`, buf);
}
const text = buf.toString('utf8');
console.log('size', buf.length);
console.log(text.slice(0, 3000));
console.log('----\n');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,33 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
async function exportXlsx(token: string, id: string, path: string) {
const mime =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=${encodeURIComponent(mime)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!r.ok) throw new Error(`${id} ${r.status} ${(await r.text()).slice(0, 300)}`);
const buf = Buffer.from(await r.arrayBuffer());
writeFileSync(path, buf);
console.log('wrote', path, buf.length);
}
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
await exportXlsx(token, ID, '/tmp/bps-cal/bps-calendario.xlsx');
await exportXlsx(token, OCT, '/tmp/bps-cal/bps-octubre.xlsx');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""Exact gap report: visible assets vs tenant matches + name candidates."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
META = "https://graph.facebook.com/v21.0"
def load_env() -> dict[str, str]:
vals: dict[str, str] = {}
for line in (ROOT / ".env.local").read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals[k.strip()] = v.strip().strip('"').strip("'")
return vals
ENV = load_env()
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def sb(path: str):
req = urllib.request.Request(BASE + path, headers=SH)
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def norm(s: str) -> str:
s = s.lower()
for a, b in (
("á", "a"),
("é", "e"),
("í", "i"),
("ó", "o"),
("ú", "u"),
("ñ", "n"),
):
s = s.replace(a, b)
return re.sub(r"[^a-z0-9]+", " ", s).strip()
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
t = norm(tenant_name)
s = norm(tenant_slug.replace("-", " "))
a = norm(account_name)
if not a:
return 0
if a == t or a == s:
return 100
if t and (t in a or a in t):
return 85
if s and (s in a or a in s):
return 80
tt = {x for x in t.split() if len(x) > 2}
aa = {x for x in a.split() if len(x) > 2}
if not tt or not aa:
return 0
hit = len(tt & aa) / len(tt)
if hit >= 0.8:
return 70
if hit >= 0.5:
return 55
return int(hit * 50)
def top_candidates(tenants, account_name, n=3):
scored = []
for t in tenants:
sc = score_name(t["name"], t["slug"], account_name)
if sc > 0:
scored.append((sc, t["name"]))
scored.sort(key=lambda x: -x[0])
return scored[:n]
def meta_paginate(token: str, path: str):
out = []
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
url = url + sep + "access_token=" + urllib.parse.quote(token)
while url:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
out.extend(j.get("data") or [])
url = (j.get("paging") or {}).get("next")
return out
def google_refresh(refresh: str) -> str:
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def main() -> int:
tenants = sb("/rest/v1/tenants?select=id,name,slug&order=name.asc")
integs = sb(
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
"&select=tenant_id,provider,status,external_account_id,meta,error_message"
)
by = defaultdict(dict)
for i in integs:
by[i["tenant_id"]][i["provider"]] = i
grow = sb(
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,account_email"
)[0]
mrow = sb(
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes"
)[0]
gtoken = google_refresh(grow["refresh_token"])
mtoken = mrow["access_token"]
print("=== TOKENS ===")
print("Google:", grow.get("account_email"))
print("Meta:", mrow.get("account_email"))
# Meta inventory
ads = []
for row in meta_paginate(
mtoken, "me/adaccounts?fields=id,account_id,name&limit=200"
):
aid = row.get("account_id") or row["id"].replace("act_", "")
ads.append({"id": f"act_{aid}", "name": row.get("name") or aid})
pages = []
igs = []
for page in meta_paginate(
mtoken,
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
):
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
igs.append(
{
"id": ig["id"],
"name": f"@{ig['username']}"
if ig.get("username")
else (ig.get("name") or page.get("name") or ig["id"]),
"page": page.get("name"),
}
)
print(f"\n=== META VISIBLE === ads={len(ads)} pages={len(pages)} ig={len(igs)}")
print("Ad accounts:")
for a in ads:
cands = top_candidates(tenants, a["name"])
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("meta_ads") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
print("Pages:")
for a in pages:
cands = top_candidates(tenants, a["name"])
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("facebook") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
print("Instagram:")
for a in igs:
cands = top_candidates(tenants, a["name"]) + top_candidates(
tenants, a.get("page") or ""
)
# dedupe keep best
seen = {}
for sc, name in cands:
seen[name] = max(sc, seen.get(name, 0))
cands = sorted(((v, k) for k, v in seen.items()), reverse=True)[:3]
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("instagram") or {}).get("external_account_id")
== a["id"]
),
None,
)
print(
f" {a['name']!r} page={a.get('page')!r} ({a['id']}) → linked={linked or 'NO'} best={cands}"
)
# GA4 / GSC inventory
req = urllib.request.Request(
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
summaries = json.loads(r.read().decode())
ga4 = []
for acc in summaries.get("accountSummaries") or []:
for p in acc.get("propertySummaries") or []:
if p.get("property"):
ga4.append(
{
"id": p["property"],
"name": p.get("displayName") or p["property"],
"account": acc.get("displayName"),
}
)
req = urllib.request.Request(
"https://www.googleapis.com/webmasters/v3/sites",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
sites_j = json.loads(r.read().decode())
gsc = [
{"id": s["siteUrl"], "name": s["siteUrl"]}
for s in (sites_j.get("siteEntry") or [])
if s.get("siteUrl")
]
print(f"\n=== GOOGLE VISIBLE === ga4={len(ga4)} gsc={len(gsc)}")
# Test Data API on first property
if ga4:
prop = ga4[0]["id"].replace("properties/", "")
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
start = end - timedelta(days=2)
body = json.dumps(
{
"dateRanges": [{"startDate": str(start), "endDate": str(end)}],
"dimensions": [{"name": "date"}],
"metrics": [{"name": "sessions"}],
}
).encode()
req = urllib.request.Request(
f"https://analyticsdata.googleapis.com/v1beta/properties/{prop}:runReport",
data=body,
headers={
"Authorization": f"Bearer {gtoken}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("Data API test: OK on", ga4[0]["name"], prop)
except urllib.error.HTTPError as e:
print("Data API test: FAIL", e.read().decode()[:250])
# Unmatched GA4 with best candidates (only those not linked)
linked_ga4_ids = {
(by[t["id"]].get("ga4") or {}).get("external_account_id")
for t in tenants
}
linked_ga4_props = set()
for t in tenants:
meta = (by[t["id"]].get("ga4") or {}).get("meta") or {}
if meta.get("property_id"):
linked_ga4_props.add(meta["property_id"])
print("\n=== GA4 sin ficha clara (score<50 o no enlazado) — top candidatos ===")
for a in ga4:
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("ga4") or {}).get("meta", {}).get("property_id")
== a["id"]
or (by[t["id"]].get("ga4") or {}).get("external_account_id")
in (a["id"], a["name"])
),
None,
)
cands = top_candidates(tenants, a["name"])
best = cands[0][0] if cands else 0
if linked and best >= 50:
continue
if linked:
continue
print(f" GA4 {a['name']!r} ({a['id']}) acct={a.get('account')!r} best={cands}")
print("\n=== GSC sin ficha clara ===")
for a in gsc:
linked = next(
(
t["name"]
for t in tenants
if (by[t["id"]].get("search_console") or {}).get("external_account_id")
== a["id"]
),
None,
)
if linked:
continue
cands = top_candidates(tenants, a["name"])
# also try domain without sc-domain
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
cands2 = top_candidates(tenants, bare)
merged = {}
for sc, name in cands + cands2:
merged[name] = max(sc, merged.get(name, 0))
cands = sorted(((v, k) for k, v in merged.items()), reverse=True)[:3]
print(f" GSC {a['name']!r} best={cands}")
# Per-tenant gaps with why
print("\n=== POR CLIENTE: qué falta y por qué ===")
providers = [
"google_ads",
"meta_ads",
"ga4",
"search_console",
"google_drive",
"facebook",
"instagram",
]
def ok(i):
return bool(i and i.get("status") == "connected" and i.get("external_account_id"))
for t in tenants:
m = by[t["id"]]
gaps = []
for p in providers:
if ok(m.get(p)):
continue
if p == "meta_ads":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ads]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not ads:
gaps.append("meta_ads: token no ve ad accounts")
elif not cands:
gaps.append("meta_ads: no hay ad account cuyo nombre se parezca (¿no está en las 9 visibles?)")
else:
gaps.append(f"meta_ads: no auto-match; candidatos {cands}")
elif p == "facebook":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in pages]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not cands:
gaps.append("facebook: ninguna Page visible se parece al nombre")
else:
gaps.append(f"facebook: no auto-match; candidatos {cands}")
elif p == "instagram":
cands = []
for a in igs:
sc = max(
score_name(t["name"], t["slug"], a["name"]),
score_name(t["name"], t["slug"], a.get("page") or ""),
)
if sc > 0:
cands.append((sc, a["name"], a.get("page"), a["id"]))
cands = sorted(cands, reverse=True)[:2]
if not cands:
gaps.append("instagram: ningún @ / page visible se parece")
else:
gaps.append(f"instagram: no auto-match; candidatos {cands}")
elif p == "ga4":
cands = [(score_name(t["name"], t["slug"], a["name"]), a["name"], a["id"]) for a in ga4]
cands = [c for c in sorted(cands, reverse=True) if c[0] > 0][:2]
if not cands:
gaps.append("ga4: ninguna propiedad visible se parece al nombre")
else:
gaps.append(f"ga4: no auto-match (umbral 50); candidatos {cands}")
elif p == "search_console":
cands = []
for a in gsc:
bare = a["name"].replace("sc-domain:", "").replace("https://", "").replace("http://", "").strip("/")
sc = max(
score_name(t["name"], t["slug"], a["name"]),
score_name(t["name"], t["slug"], bare),
)
if sc > 0:
cands.append((sc, a["name"]))
cands = sorted(cands, reverse=True)[:2]
if not cands:
gaps.append("gsc: ningún site visible se parece")
else:
gaps.append(f"gsc: no auto-match (umbral 40); candidatos {cands}")
elif p == "google_drive":
gaps.append("drive: sin carpeta auto-matched (revisar nombre carpeta Drive)")
elif p == "google_ads":
gaps.append("google_ads: sin cuenta enlazada (MCC / nombre)")
if gaps:
print(f"\n{t['name']}:")
for g in gaps:
print(f" - {g}")
else:
print(f"\n{t['name']}: COMPLETO (todos los canales con ID)")
# BPS GSC permission probe
bps = next(t for t in tenants if t["name"] == "BPS")
gsc_id = (by[bps["id"]].get("search_console") or {}).get("external_account_id")
if gsc_id:
end = (datetime.now(timezone.utc) - timedelta(days=1)).date()
start = end - timedelta(days=3)
body = json.dumps(
{"startDate": str(start), "endDate": str(end), "dimensions": ["date"]}
).encode()
req = urllib.request.Request(
f"https://www.googleapis.com/webmasters/v3/sites/{urllib.parse.quote(gsc_id, safe='')}/searchAnalytics/query",
data=body,
headers={
"Authorization": f"Bearer {gtoken}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("\nBPS GSC probe: OK")
except urllib.error.HTTPError as e:
print("\nBPS GSC probe: FAIL", e.read().decode()[:220])
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,22 @@
/**
* Regenerate public/plantillas/calendario-rrss-oficial.xlsx
* npx tsx scripts/generate_calendar_template.ts
*/
import { writeFileSync } from 'fs';
import { resolve } from 'path';
import { buildOfficialXlsx } from '../src/lib/calendar-template';
async function main() {
const { buffer, filename } = await buildOfficialXlsx('CLIENTE');
const out = resolve(
process.cwd(),
'public/plantillas/calendario-rrss-oficial.xlsx'
);
writeFileSync(out, buffer);
console.log('Wrote', out, `(${buffer.length} bytes) · ${filename}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,457 @@
/**
* Import BPS editorial calendars from Drive xlsx into documents (type=calendar).
* Includes hidden month tabs (JULIO…JUNIO, ENERO 2026, *26).
*
* Prerequisite: /tmp/bps-cal/bps-calendario.xlsx (+ optional bps-octubre.xlsx)
* npx tsx scripts/export_bps_xlsx.ts
* npx tsx scripts/import_bps_calendars.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { execFileSync } from 'child_process';
config({ path: resolve(process.cwd(), '.env.local') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
const BPS_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
/**
* Explicit year/month for every editorial month sheet (incl. hidden).
* Workbook order: JUL–DIC 2025 → ENE–JUN 2026 → JUL–OCT 26.
*/
const SHEET_YM: Record<string, string> = {
JULIO: '2025-07',
AGOSTO: '2025-08',
SEPTIEMBRE: '2025-09',
OCTUBRE: '2025-10',
NOVIEMBRE: '2025-11',
DICIEMBRE: '2025-12',
'ENERO 2026': '2026-01',
FEBRERO: '2026-02',
MARZO: '2026-03',
ABRIL: '2026-04',
MAYO: '2026-05',
JUNIO: '2026-06',
'JULIO 26': '2026-07',
'AGOSTO 26': '2026-08',
'SEPTIMEBRE 26': '2026-09',
'OCTUBRE 26': '2026-10',
};
const MONTH_SHEET_NAMES = new Set(Object.keys(SHEET_YM));
function slugify(s: string) {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
function parseDayHeader(h: string): number | null {
const raw = String(h || '').trim();
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null;
const m = raw.match(/(\d{1,2})\s*$/);
if (!m) return null;
const n = Number(m[1]);
return n >= 1 && n <= 31 ? n : null;
}
type SheetRows = (string | number | null)[][];
/** Parse SEMANA/STORIES/POST grid into pieces. */
function parseWeekGrid(
sheetName: string,
rows: SheetRows,
year: number,
month: number
): RrssPiece[] {
const pieces: RrssPiece[] = [];
let dayCols: { col: number; day: number }[] = [];
let kind: 'STORIES' | 'POST' | null = null;
/** Week wraps when header has e.g. LUNES 30 … MARTES 1 (spillover from prev month). */
let weekWraps = false;
for (const row of rows) {
if (!row || !row.some((c) => c != null && String(c).trim())) continue;
const c0 = String(row[0] ?? '')
.trim()
.toUpperCase();
if (
c0.startsWith('SEMANA') ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(String(row[1] ?? ''))
) {
dayCols = [];
for (let col = 1; col < Math.min(row.length, 10); col++) {
const day = parseDayHeader(String(row[col] ?? ''));
if (day) dayCols.push({ col, day });
}
weekWraps = dayCols.some(
(d, i) =>
i > 0 &&
d.day < dayCols[i - 1].day &&
dayCols[i - 1].day >= 28 &&
d.day <= 7
);
kind = null;
continue;
}
if (c0.startsWith('STORIES') || c0 === 'STORY') {
kind = 'STORIES';
} else if (c0.startsWith('POST')) {
kind = 'POST';
} else if (!kind) {
continue;
}
for (const { col, day } of dayCols) {
const raw = row[col];
if (raw == null) continue;
const text = String(raw).trim();
if (!text || text === 'X' || /^columna/i.test(text)) continue;
let y = year;
let m = month;
if (weekWraps && day >= 28) {
m = month - 1;
if (m < 1) {
m = 12;
y = year - 1;
}
}
const mm = String(m).padStart(2, '0');
const dd = String(day).padStart(2, '0');
const date = `${y}-${mm}-${dd}`;
const dt = new Date(`${date}T12:00:00Z`);
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
const id = `bps-${date}-${kind === 'STORIES' ? 'st' : 'post'}-${slugify(text).slice(0, 24)}`;
pieces.push({
id,
date,
platform: 'Instagram',
format: kind === 'STORIES' ? 'Stories' : 'Post',
pillar: kind === 'STORIES' ? 'stories' : 'feed',
title: text.split('\n')[0].slice(0, 120),
body: text,
status: 'planned',
kind: kind === 'STORIES' ? 'story' : 'post',
color: kind === 'STORIES' ? '#D9821F' : '#2071cf',
});
}
}
const seen = new Set<string>();
return pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
}
async function loadSheetsFromPython(): Promise<
{ name: string; rows: SheetRows; state: string }[]
> {
const py = `
import openpyxl, json
wb=openpyxl.load_workbook('/tmp/bps-cal/bps-calendario.xlsx', data_only=True)
out=[]
month_names={${[...MONTH_SHEET_NAMES].map((n) => JSON.stringify(n)).join(',')}}
for name in wb.sheetnames:
ws=wb[name]
keep = name in month_names or name == 'Calendario'
if not keep:
continue
rows=[]
# Read enough rows; openpyxl max_row is often inflated (1000) — stop after 80 blank streak
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), values_only=True),1):
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
if not any(v is not None and str(v).strip() for v in vals):
blank+=1
if blank>15 and i>30: break
continue
blank=0
rows.append(vals)
out.append({'name': name, 'rows': rows, 'state': ws.sheet_state})
try:
wb2=openpyxl.load_workbook('/tmp/bps-cal/bps-octubre.xlsx', data_only=True)
for name in wb2.sheetnames:
ws=wb2[name]
rows=[]
blank=0
for i,row in enumerate(ws.iter_rows(min_row=1, max_row=min(ws.max_row, 80), values_only=True),1):
vals=[None if c is None else (c.isoformat() if hasattr(c,'isoformat') else c) for c in row[:14]]
if not any(v is not None and str(v).strip() for v in vals):
blank+=1
if blank>10: break
continue
blank=0
rows.append(vals)
out.append({'name': name+' (prov)', 'rows': rows, 'state': 'visible'})
except Exception:
pass
print(json.dumps(out, ensure_ascii=False, default=str))
`;
const raw = execFileSync('python3', ['-c', py], { maxBuffer: 20_000_000 });
return JSON.parse(raw.toString('utf8'));
}
function parseStructuredCalendario(rows: SheetRows): Map<string, RrssPiece[]> {
const byMonth = new Map<string, RrssPiece[]>();
for (const r of rows.slice(1)) {
if (!r) continue;
const dia = r[2];
const formato = String(r[3] ?? '');
const contenido = String(r[4] ?? '').trim();
const estado = String(r[6] ?? '');
if (!contenido && !formato) continue;
let dateStr = '';
if (typeof dia === 'string' && /^\d{4}-\d{2}-\d{2}/.test(dia)) {
dateStr = dia.slice(0, 10);
} else {
continue;
}
const monthKey = dateStr.slice(0, 7);
const isStory = /storie/i.test(formato);
const isBlog = /blog/i.test(formato);
const piece: RrssPiece = {
id: `bps-${dateStr}-${isStory ? 'st' : isBlog ? 'blog' : 'post'}-${slugify(contenido || formato)}`,
date: dateStr,
platform: isBlog ? 'Blog' : 'Instagram',
format: isStory ? 'Stories' : isBlog ? 'Blog' : 'Post',
pillar: isStory ? 'stories' : 'feed',
title: (contenido || formato).split('\n')[0].slice(0, 120) || formato,
body: contenido || undefined,
status: /subido/i.test(estado)
? 'published'
: /pendiente/i.test(estado)
? 'planned'
: 'planned',
kind: isStory ? 'story' : 'post',
color: isStory ? '#D9821F' : '#2071cf',
};
const list = byMonth.get(monthKey) || [];
list.push(piece);
byMonth.set(monthKey, list);
}
return byMonth;
}
function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
const names = [
'',
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
return `${names[m]} ${y}`;
}
function buildPlan(ym: string, pieces: RrssPiece[]): RrssMonthPlan {
const posts = pieces.filter((p) => p.kind !== 'story').length;
const stories = pieces.filter((p) => p.kind === 'story').length;
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: '4VENTURES × BLUE PARROT SCHOOL · PLAN RRSS',
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Importado desde Drive (CALENDARIO Blue Parrot School), incluidas pestañas ocultas.',
chips: [
`${posts} posts`,
`${stories} stories`,
'Fuente: Google Drive',
],
},
pieces: pieces.sort((a, b) => a.date.localeCompare(b.date)),
};
}
async function upsertPlan(plan: RrssMonthPlan) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const base = supabaseUrl();
const title = `RRSS ${plan.label}`;
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
{ headers }
);
const docs = (await existing.json()) as {
id: string;
title: string;
content: unknown;
}[];
for (const d of docs) {
let c = d.content as RrssMonthPlan | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as RrssMonthPlan;
} catch {
c = null;
}
}
const month = c && typeof c === 'object' ? c.month : null;
const sameTitle = d.title === title || d.title === `RRSS ${plan.label}`;
if (month === plan.month || sameTitle) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
}
}
const row = {
tenant_id: BPS_ID,
type: 'calendar',
title,
content: plan,
status: 'approved',
};
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!ins.ok) throw new Error(`insert ${plan.month}: ${(await ins.text()).slice(0, 300)}`);
const saved = (await ins.json()) as { id: string }[];
console.log('OK', plan.month, plan.pieces.length, 'pieces', saved[0]?.id);
}
function mergePieces(a: RrssPiece[], b: RrssPiece[]): RrssPiece[] {
const ids = new Set(a.map((p) => p.id));
return a.concat(b.filter((p) => !ids.has(p.id)));
}
async function main() {
const sheets = await loadSheetsFromPython();
const byMonth = new Map<string, RrssPiece[]>();
const gridMonths = new Set<string>();
// 1) All month tabs (hidden + visible) — primary source
for (const sh of sheets) {
const baseName = sh.name.replace(' (prov)', '');
const ym = SHEET_YM[baseName];
if (!ym) continue;
const [year, month] = ym.split('-').map(Number);
const pieces = parseWeekGrid(sh.name, sh.rows as SheetRows, year, month);
const cur = byMonth.get(ym) || [];
if (sh.name.includes('(prov)') && pieces.length >= cur.length) {
byMonth.set(ym, pieces);
} else {
byMonth.set(ym, mergePieces(cur, pieces));
}
if (pieces.length) gridMonths.add(ym);
console.log(
`parsed ${sh.name} [${sh.state}] → ${ym} ${pieces.length} pieces`
);
}
// 2) Structured "Calendario" sheet: only fill months that have NO week-grid tab
// (May–Aug 2025 detail) or merge blog rows into existing months
for (const sh of sheets) {
if (sh.name !== 'Calendario') continue;
const structured = parseStructuredCalendario(sh.rows as SheetRows);
for (const [ym, pieces] of structured) {
if (gridMonths.has(ym)) {
// keep grid as source of truth; still add blog-only pieces
const blogs = pieces.filter((p) => p.platform === 'Blog');
if (blogs.length) {
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], blogs));
console.log(`calendario blogs → ${ym} +${blogs.length}`);
}
continue;
}
byMonth.set(ym, mergePieces(byMonth.get(ym) || [], pieces));
console.log(`calendario only → ${ym} ${pieces.length}`);
}
}
// Drop wrongly-year'd leftovers from prior import (e.g. Feb–Apr as 2025)
const keepMonths = new Set(byMonth.keys());
writeFileSync(
'/tmp/bps-cal/plans-summary.json',
JSON.stringify(
[...byMonth.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([m, p]) => ({ month: m, n: p.length })),
null,
2
)
);
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const existing = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${BPS_ID}&type=eq.calendar&select=id,title,content`,
{ headers }
);
const docs = (await existing.json()) as {
id: string;
content: unknown;
}[];
for (const d of docs) {
let c = d.content as { month?: string } | string | null;
if (typeof c === 'string') {
try {
c = JSON.parse(c) as { month?: string };
} catch {
c = null;
}
}
const m = c && typeof c === 'object' ? c.month : null;
if (!m) continue;
// Remove old 2025-02..04 if we now have 2026-02..04 from hidden sheets
if (
/^2025-0[2-4]$/.test(m) &&
keepMonths.has(m.replace('2025', '2026'))
) {
await fetch(`${base}/rest/v1/documents?id=eq.${d.id}`, {
method: 'DELETE',
headers: { ...headers, Prefer: 'return=minimal' },
});
console.log('deleted stale', m, d.id);
}
}
}
for (const [ym, pieces] of [...byMonth.entries()].sort()) {
if (!pieces.length) continue;
await upsertPlan(buildPlan(ym, pieces));
}
console.log(
'months',
[...byMonth.keys()].sort().join(', '),
'total pieces',
[...byMonth.values()].reduce((s, p) => s + p.length, 0)
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,627 @@
/**
* Import editorial RRSS calendars from Drive export folder into documents.
*
* npx tsx scripts/import_drive_calendars_batch.ts
*
* Source: /Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001
* Months per client as requested by ops (Oct 2026).
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { execFileSync } from 'child_process';
config({ path: resolve(process.cwd(), '.env.local') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import type { RrssMonthPlan, RrssPiece } from '../src/components/RrssMonthView';
const ROOT =
'/Users/4ventures/Downloads/drive-download-20261008T140949Z-1-001';
type SheetRows = (string | number | null)[][];
type ClientJob = {
slug: string;
brand: string;
file: string;
/** sheet name → year-month */
sheets: Record<string, string>;
/** if set, only upsert these months (others parsed ignored) */
onlyMonths: string[];
};
const JOBS: ClientJob[] = [
{
slug: 'bps',
brand: 'Blue Parrot School',
file: `${ROOT}/BLUE PARROT/CALENDARIO Blue Parrot School.xlsx`,
sheets: {
JULIO: '2025-07',
AGOSTO: '2025-08',
SEPTIEMBRE: '2025-09',
OCTUBRE: '2025-10',
NOVIEMBRE: '2025-11',
DICIEMBRE: '2025-12',
'ENERO 2026': '2026-01',
FEBRERO: '2026-02',
MARZO: '2026-03',
ABRIL: '2026-04',
MAYO: '2026-05',
JUNIO: '2026-06',
'JULIO 26': '2026-07',
'AGOSTO 26': '2026-08',
'SEPTIMEBRE 26': '2026-09',
'OCTUBRE 26': '2026-10',
},
onlyMonths: [
'2025-05',
'2025-06',
'2025-07',
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
'2026-08',
'2026-09',
'2026-10',
],
},
{
slug: 'brava-dermoestetica',
brand: 'Brava Dermoestética',
file: `${ROOT}/BRAVA/CALENDARIO RRSS BRAVA.xlsx`,
sheets: {
'ABRIL 2026': '2026-04',
'MAYO 2026': '2026-05',
'JUNIO 2026': '2026-06',
'JULIO 2026': '2026-07',
'AGOSTO 2026': '2026-08',
'SEPTIEMBRE 2026': '2026-09',
'Octubre 2026': '2026-10',
},
onlyMonths: [
'2026-04',
'2026-05',
'2026-06',
'2026-07',
'2026-08',
'2026-09',
'2026-10',
],
},
{
slug: '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()

95
scripts/ingest_gads_json.py Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Ingest .ops/gads/{customer_id}.json MCP dumps into ad_daily."""
from __future__ import annotations
import json, os, sys, urllib.request, urllib.error
from pathlib import Path
from datetime import datetime, timezone, timedelta
ROOT = Path('/Users/4ventures/4ventures-client-portal')
# load .env.local
for line in (ROOT/'.env.local').read_text().splitlines():
if '=' in line and not line.startswith('#') and line.split('=',1)[0] not in os.environ:
k,v=line.split('=',1); os.environ[k]=v.strip().strip('"').strip("'")
url=os.environ['NEXT_PUBLIC_SUPABASE_URL'].rstrip('/')
key=os.environ['SUPABASE_SERVICE_ROLE_KEY']
h={'apikey':key,'Authorization':f'Bearer {key}','Content-Type':'application/json'}
MAP={
'3019377678':'a7a6eb49-435c-411e-bdb0-a45b6a75ffab',
'7658651782':'2f6587f1-5dab-4994-b99f-d62f4300dd92',
'4975185233':'25d3cdda-a3fc-4af7-b733-b38304b489df',
'3183140753':'506a6d00-dc5a-4d84-bedc-73a35e3014a6',
'8746480744':'98d023ba-1c5c-4cab-8ab9-4e70c4367bb1',
'7181044127':'6f5c19e4-bf8b-4ef0-8c7e-55c9d0c0a041',
'8436023380':'97def34e-e1c4-41b4-a579-036b14cd5091',
'2986275882':'229687b5-6861-4b6d-b321-e09197629731',
'6481978772':'8e5761b7-6347-4fd1-acbf-4572730a783a',
'9069365731':'73a1b415-2325-4cab-9769-823021a5e888',
'8177742252':'730feaf2-f2a3-4a18-963b-12b853551d8d',
'9627541456':'fd7d361b-c4d0-47ca-bc0e-75d21ac50da3',
'8592129580':'7df5293e-b954-4059-94da-d18534a08767',
'6871069003':'c538a248-4ef0-437b-b4a0-544cff597659',
'2508925045':'a5a250b4-e79a-4cb3-bfa1-4f9a6d908a03',
'4418211066':'48919ad2-6af0-4ef0-96a3-fd9ca0a46cd2',
'1323363601':'0b3c5fc0-6901-4caf-b26a-b6a0201d8541',
'1064441284':'8bec977a-6032-4759-aa7f-b5042081f450', # BPS / Blue Parrot School
}
def call(method, path, body=None):
headers=dict(h); headers['Prefer']='return=minimal'
data=None if body is None else json.dumps(body).encode()
req=urllib.request.Request(f'{url}{path}', data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:400]
since=(datetime.now(timezone.utc)-timedelta(days=30)).date().isoformat()
gads=ROOT/'.ops'/'gads'
total_rows=0; total_spend=0.0
for path in sorted(gads.glob('*.json')):
cid=path.stem
tid=MAP.get(cid)
if not tid:
print('skip unknown', cid); continue
rows_raw=json.loads(path.read_text())
if not rows_raw:
print(cid, 'EMPTY'); continue
out=[]
for r in rows_raw:
date=r.get('segments.date') or (r.get('segments') or {}).get('date')
if not date: continue
camp_id=r.get('campaign.id') or (r.get('campaign') or {}).get('id')
camp_name=r.get('campaign.name') or (r.get('campaign') or {}).get('name')
imp=r.get('metrics.impressions') or (r.get('metrics') or {}).get('impressions') or 0
clicks=r.get('metrics.clicks') or (r.get('metrics') or {}).get('clicks') or 0
cost=r.get('metrics.cost_micros') or (r.get('metrics') or {}).get('costMicros') or (r.get('metrics') or {}).get('cost_micros') or 0
conv=r.get('metrics.conversions') or (r.get('metrics') or {}).get('conversions') or 0
cid_s = str(camp_id) if camp_id is not None else ''
out.append({
'tenant_id': tid,
'date': date,
'platform': 'google',
'campaign_id': cid_s or None,
'campaign_name': camp_name,
# ad_daily.ad_id is NOT NULL — campaign-level rows reuse campaign id
'ad_id': cid_s,
'ad_name': '',
'impressions': int(float(imp or 0)),
'clicks': int(float(clicks or 0)),
'spend': round(float(cost or 0)/1_000_000, 2),
'platform_leads': int(round(float(conv or 0))),
})
call('DELETE', f'/rest/v1/ad_daily?tenant_id=eq.{tid}&platform=eq.google&date=gte.{since}')
ok=True
for i in range(0,len(out),400):
st,res=call('POST','/rest/v1/ad_daily', out[i:i+400])
if st not in (200,201):
print('FAIL', cid, st, res); ok=False; break
if ok:
spend=sum(x['spend'] for x in out)
total_rows+=len(out); total_spend+=spend
print(f'OK {cid} rows={len(out)} spend={spend:.2f}')
print('TOTAL', total_rows, round(total_spend,2))

View File

@@ -0,0 +1,53 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const ID = '1YPcHBjioiHHQy22Q2xevkvUqA5jMq87v6IviV7VTL2s';
const OCT = '1-Zss1suBLDxAgqUuP9DrJYWHiicyin8aQqMOTIIekAg';
async function main() {
const token = await getValidAccessToken();
mkdirSync('/tmp/bps-cal', { recursive: true });
for (const sid of [ID, OCT]) {
const r = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sid}?fields=properties.title,sheets.properties`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const j = (await r.json()) as {
properties?: { title?: string };
sheets?: { properties?: { title?: string; sheetId?: number } }[];
error?: unknown;
};
console.log('status', r.status, JSON.stringify(j).slice(0, 500));
console.log('\n===', j.properties?.title, '===');
for (const s of j.sheets || []) {
const title = s.properties?.title || '';
console.log(' sheet:', title);
const csv = await fetch(
`https://docs.google.com/spreadsheets/d/${sid}/export?format=csv&gid=${s.properties?.sheetId ?? 0}`,
{ headers: { Authorization: `Bearer ${token}` }, redirect: 'follow' }
);
// Sheets export via docs URL may need alternate - use values API
const vals = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sid}/values/${encodeURIComponent(title)}!A1:Z80`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const vj = (await vals.json()) as { values?: string[][] };
const rows = vj.values || [];
const safe = title.replace(/[^\w.-]+/g, '_');
writeFileSync(
`/tmp/bps-cal/${sid.slice(0, 6)}_${safe}.json`,
JSON.stringify(rows, null, 2)
);
console.log(' rows', rows.length, 'head0', (rows[0] || []).slice(0, 8).join(' | '));
console.log(' head1', (rows[1] || []).slice(0, 8).join(' | '));
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
/** List Drive calendar folder and find BPS files. */
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
const FOLDER = '12tPM0_nuVEf9MBUeyipmcWAJ-gFFgqXh';
async function listChildren(token: string, parentId: string) {
const out: { id: string; name: string; mimeType: string }[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
q: `'${parentId}' in parents and trashed=false`,
fields: 'nextPageToken,files(id,name,mimeType,modifiedTime)',
pageSize: '100',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
corpora: 'allDrives',
});
if (pageToken) params.set('pageToken', pageToken);
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const j = (await r.json()) as {
files?: { id: string; name: string; mimeType: string }[];
nextPageToken?: string;
};
if (!r.ok) throw new Error(JSON.stringify(j).slice(0, 400));
out.push(...(j.files || []));
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
async function main() {
const token = await getValidAccessToken();
console.log('token ok');
const top = await listChildren(token, FOLDER);
console.log('top count', top.length);
for (const f of top.sort((a, b) => a.name.localeCompare(b.name))) {
const kind = f.mimeType.includes('folder') ? 'DIR' : 'FILE';
console.log(`${kind}\t${f.name}\t${f.id}`);
}
const bps = top.filter((f) => /bps|blue.?parrot|parrot/i.test(f.name));
console.log('\nBPS matches', bps.length);
for (const f of bps) {
if (f.mimeType.includes('folder')) {
const kids = await listChildren(token, f.id);
console.log(`\n--- ${f.name} (${kids.length}) ---`);
for (const k of kids.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(
` ${k.mimeType.includes('folder') ? 'DIR' : 'FILE'}\t${k.name}\t${k.id}`
);
}
}
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,101 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { getValidAccessToken } from '../src/lib/google-drive';
import {
buildSeguimientoRowsForDoc,
upsertSeguimientoRows,
patchSuperhumanIntegration,
findSeguimientoGoogleDoc,
exportGoogleDocHtml,
splitHtmlNotesByDay,
} from '../src/lib/superhuman-docs';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const DOC = '7TGywaNiUY';
async function driveSearch(q: string) {
const token = await getValidAccessToken();
const params = new URLSearchParams({
q,
fields: 'files(id,name,mimeType,webViewLink,modifiedTime)',
pageSize: '30',
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
});
const r = await fetch(`https://www.googleapis.com/drive/v3/files?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(await r.text());
return ((await r.json()) as { files?: unknown[] }).files || [];
}
async function main() {
console.log(
'search seguimiento+parrot',
await driveSearch(
"name contains 'Seguimiento' and name contains 'Parrot' and trashed=false"
)
);
console.log(
'search seguimiento+BPS',
await driveSearch(
"name contains 'Seguimiento' and name contains 'BPS' and trashed=false"
)
);
console.log(
'search Blue Parrot docs',
await driveSearch(
"name contains 'Blue Parrot' and mimeType='application/vnd.google-apps.document' and trashed=false"
)
);
console.log(
'findSeguimiento',
await findSeguimientoGoogleDoc([
'Seguimiento Semanal Blue Parrot School',
'Seguimiento Semanal BPS',
'Seguimiento Semanal Blue Parrot',
'Blue Parrot School',
'BPS',
])
);
// Re-run full build (includes Drive embed discovery from Superhuman pages)
const rows = await buildSeguimientoRowsForDoc({
tenantId: BPS,
tenantName: 'Blue Parrot School',
docId: DOC,
maxEntries: 120,
exportBodies: true,
});
console.log(
'rows',
rows.length,
rows.slice(0, 8).map((r) => ({
title: r.title,
occurred: r.occurred_at,
ext: r.external_id,
bodyLen: (r.body_md || '').length,
}))
);
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
entries: n,
synced_at: new Date().toISOString(),
},
});
console.log('upserted', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
listDocs,
bestDocForTenant,
cleanClientDocName,
} from '../src/lib/superhuman-docs';
import { scoreNameToTenant } from '../src/lib/match';
import { findSeguimientoGoogleDoc } from '../src/lib/superhuman-docs';
async function main() {
const docs = await listDocs();
console.log('docs', docs.length);
for (const d of docs) {
const clean = cleanClientDocName(d.name);
const s1 = scoreNameToTenant(clean, 'BPS', 'bps');
const s2 = scoreNameToTenant(clean, 'Blue Parrot School', 'blue-parrot');
if (
s1 >= 15 ||
s2 >= 15 ||
/parrot|bps|blue/i.test(d.name)
) {
console.log({ name: d.name, clean, s1, s2, id: d.id });
}
}
console.log(
'best BPS',
bestDocForTenant({ name: 'BPS', slug: 'bps' }, docs)
);
console.log(
'best Blue Parrot School',
bestDocForTenant(
{ name: 'Blue Parrot School', slug: 'blue-parrot-school' },
docs
)
);
const drive = await findSeguimientoGoogleDoc([
'Seguimiento Semanal BPS',
'Seguimiento Semanal Blue Parrot School',
'Seguimiento Semanal Blue Parrot',
'BPS',
'Blue Parrot School',
]);
console.log('drive hit', drive);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,41 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
exportGoogleDocHtml,
splitHtmlNotesByDay,
splitNotesByDay,
} from '../src/lib/superhuman-docs';
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
async function main() {
const html = await exportGoogleDocHtml(FILE);
console.log('html len', html.length);
console.log('sample', html.slice(0, 1500));
// find date-like patterns
const dates = html.match(/\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4}/g) || [];
console.log('date-like', [...new Set(dates)].slice(0, 40));
const headings = html.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || [];
console.log(
'headings',
headings.slice(0, 20).map((h) => h.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim())
);
const secs = splitHtmlNotesByDay(html);
console.log('html sections', secs.length, secs.slice(0, 3).map((s) => s.label));
const plain = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '\n');
const plainSecs = splitNotesByDay(plain);
console.log(
'plain sections',
plainSecs.length,
plainSecs.slice(0, 5).map((s) => s.label)
);
console.log('plain sample\n', plain.slice(0, 2000));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,136 @@
/**
* Provision agency admin accounts (role=admin, tenant=4Ventures Central).
* npx tsx scripts/provision_agency_admins.ts
*
* Writes credentials to .ops/agency-admins-credentials.txt (gitignored).
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync, mkdirSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
import { randomBytes } from 'crypto';
config({ path: resolve(process.cwd(), '.env.local') });
const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
const COMPANY = '4Ventures';
const SLUG = '4ventures-hub';
const EMAILS = [
'marcos@4ventures.es',
'belen@4ventures.es',
'rebecca@4ventures.es',
'victor@4ventures.es',
] as const;
function tempPassword(local: string): string {
const tag = local.split('@')[0].replace(/[^a-z]/gi, '').slice(0, 8) || 'User';
const cap = tag.charAt(0).toUpperCase() + tag.slice(1).toLowerCase();
return `4V${cap}-${randomBytes(4).toString('hex')}!9`;
}
async function findUser(
admin: ReturnType<typeof createClient>,
email: string
) {
for (let page = 1; page <= 40; page++) {
const { data, error } = await admin.auth.admin.listUsers({
page,
perPage: 200,
});
if (error) throw error;
const hit = data.users.find(
(u) => (u.email || '').toLowerCase() === email.toLowerCase()
);
if (hit) return hit;
if (data.users.length < 200) break;
}
return null;
}
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) {
throw new Error(
'Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local'
);
}
const admin = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
const lines: string[] = [
'# Agency admin access — app.4ventures.es/login',
`# generated ${new Date().toISOString()}`,
`# tenant ${COMPANY} (${HUB_TENANT}) role=admin`,
'',
];
for (const email of EMAILS) {
const password = tempPassword(email);
const app_metadata = {
role: 'admin',
tenant_id: HUB_TENANT,
company: COMPANY,
slug: SLUG,
};
const user_metadata = {
role: 'admin',
tenant_id: HUB_TENANT,
company: COMPANY,
slug: SLUG,
};
const found = await findUser(admin, email);
if (found) {
const { error } = await admin.auth.admin.updateUserById(found.id, {
password,
email_confirm: true,
app_metadata: { ...(found.app_metadata || {}), ...app_metadata },
user_metadata: { ...(found.user_metadata || {}), ...user_metadata },
});
if (error) throw new Error(`${email}: ${error.message}`);
console.log('updated', email, found.id);
lines.push(`email=${email}`);
lines.push(`password=${password}`);
lines.push(`user_id=${found.id}`);
lines.push(`action=updated`);
lines.push('');
} else {
const { data, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
app_metadata,
user_metadata,
});
if (error) throw new Error(`${email}: ${error.message}`);
console.log('created', email, data.user?.id);
lines.push(`email=${email}`);
lines.push(`password=${password}`);
lines.push(`user_id=${data.user?.id || ''}`);
lines.push(`action=created`);
lines.push('');
}
}
mkdirSync(resolve(process.cwd(), '.ops'), { recursive: true });
const out = resolve(process.cwd(), '.ops/agency-admins-credentials.txt');
writeFileSync(out, lines.join('\n'), { mode: 0o600 });
console.log('---');
console.log('credentials', out);
console.log('login https://app.4ventures.es/login');
for (const email of EMAILS) {
const pass = lines.find(
(l, i) => lines[i - 1] === `email=${email}` && l.startsWith('password=')
);
console.log(email, pass?.replace('password=', '') || '?');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
/**
* Provision victor@4ventures.es as BPS client portal user.
* npx tsx scripts/provision_victor_bps.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { writeFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
import { randomBytes } from 'crypto';
config({ path: resolve(process.cwd(), '.env.local') });
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const EMAIL = 'victor@4ventures.es';
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
const admin = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
const password = `BpsVictor-${randomBytes(4).toString('hex')}!9`;
let found: { id: string; app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> } | null =
null;
for (let page = 1; page <= 30; page++) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage: 200 });
if (error) throw error;
const hit = data.users.find((u) => (u.email || '').toLowerCase() === EMAIL);
if (hit) {
found = hit;
break;
}
if (data.users.length < 200) break;
}
const app_metadata = {
...(found?.app_metadata || {}),
role: 'client',
tenant_id: BPS,
company: 'Blue Parrot School',
};
const user_metadata = {
...(found?.user_metadata || {}),
role: 'client',
tenant_id: BPS,
company: 'Blue Parrot School',
};
if (found) {
const { error } = await admin.auth.admin.updateUserById(found.id, {
password,
email_confirm: true,
app_metadata,
user_metadata,
});
if (error) throw error;
console.log('updated', found.id);
} else {
const { data, error } = await admin.auth.admin.createUser({
email: EMAIL,
password,
email_confirm: true,
app_metadata,
user_metadata,
});
if (error) throw error;
console.log('created', data.user?.id);
}
writeFileSync(
resolve(process.cwd(), '.ops/victor-bps-credentials.txt'),
[
`email=${EMAIL}`,
`password=${password}`,
`tenant=Blue Parrot School (${BPS})`,
`role=client`,
`updated_at=${new Date().toISOString()}`,
'',
].join('\n'),
{ mode: 0o600 }
);
console.log('---');
console.log('url https://app.4ventures.es/login');
console.log('email', EMAIL);
console.log('password', password);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""Refresh Meta + Google agency matches into client fichas and audit."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
META = "https://graph.facebook.com/v21.0"
GADS = "https://googleads.googleapis.com/v18"
def load_env() -> dict[str, str]:
vals: dict[str, str] = {}
for p in (ROOT / ".env.local", Path("/Users/4ventures/Projects/bps-facturacion/.env")):
if not p.is_file():
continue
for line in p.read_text(encoding="utf-8").splitlines():
if not line.strip() or line.strip().startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
vals.setdefault(k.strip(), v.strip().strip('"').strip("'"))
return vals
ENV = load_env()
BASE = ENV["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/")
KEY = ENV["SUPABASE_SERVICE_ROLE_KEY"]
SH = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
def sb_get(path: str):
req = urllib.request.Request(BASE + path, headers=SH)
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def sb_patch(path: str, body: dict) -> bool:
data = json.dumps(body).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={**SH, "Content-Type": "application/json", "Prefer": "return=minimal"},
method="PATCH",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return 200 <= r.status < 300
except urllib.error.HTTPError:
return False
def sb_upsert_integ(tenant_id: str, provider: str, patch: dict) -> bool:
path = (
f"/rest/v1/integrations?tenant_id=eq.{urllib.parse.quote(tenant_id)}"
f"&provider=eq.{urllib.parse.quote(provider)}"
)
body = {**patch, "updated_at": datetime.now(timezone.utc).isoformat()}
if sb_patch(path, body):
return True
data = json.dumps(
{"tenant_id": tenant_id, "provider": provider, "enabled": True, **body}
).encode()
req = urllib.request.Request(
BASE + "/rest/v1/integrations",
data=data,
headers={
**SH,
"Content-Type": "application/json",
"Prefer": "resolution=merge-duplicates,return=minimal",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as _:
return True
except urllib.error.HTTPError as e:
print("upsert fail", provider, e.read().decode()[:200])
return False
def norm(s: str) -> str:
s = s.lower()
s = (
s.replace("á", "a")
.replace("é", "e")
.replace("í", "i")
.replace("ó", "o")
.replace("ú", "u")
.replace("ñ", "n")
)
return re.sub(r"[^a-z0-9]+", " ", s).strip()
def score_name(tenant_name: str, tenant_slug: str, account_name: str) -> int:
t = norm(tenant_name)
s = norm(tenant_slug.replace("-", " "))
a = norm(account_name)
if not a:
return 0
if a == t or a == s:
return 100
if t and (t in a or a in t):
return 85
if s and (s in a or a in s):
return 80
tt = set(x for x in t.split() if len(x) > 2)
aa = set(x for x in a.split() if len(x) > 2)
if not tt or not aa:
return 0
hit = len(tt & aa) / len(tt)
if hit >= 0.8:
return 70
if hit >= 0.5:
return 55
return int(hit * 50)
def best_matches(tenants, accounts, threshold=55):
"""Greedy 1:1 match like portal."""
scored = []
for t in tenants:
for acc in accounts:
sc = score_name(t["name"], t["slug"], acc["name"])
if sc >= threshold:
scored.append((sc, t["id"], acc))
scored.sort(key=lambda x: -x[0])
used_t, used_a = set(), set()
out = {}
for sc, tid, acc in scored:
if tid in used_t or acc["id"] in used_a:
continue
used_t.add(tid)
used_a.add(acc["id"])
out[tid] = (acc, sc)
return out
def meta_get(token: str, path: str):
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
req = urllib.request.Request(url + sep + "access_token=" + urllib.parse.quote(token))
with urllib.request.urlopen(req, timeout=90) as r:
return json.loads(r.read().decode())
def meta_paginate(token: str, path: str):
out = []
url = f"{META}/{path}"
sep = "&" if "?" in path else "?"
url = url + sep + "access_token=" + urllib.parse.quote(token)
while url:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
out.extend(j.get("data") or [])
url = (j.get("paging") or {}).get("next")
return out
def google_refresh(refresh: str) -> str:
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": ENV["GOOGLE_OAUTH_CLIENT_ID"],
"client_secret": ENV["GOOGLE_OAUTH_CLIENT_SECRET"],
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def gads_access() -> str:
refresh = ENV.get("GOOGLE_ADS_REFRESH_TOKEN") or ENV.get("GOOGLE_OAUTH_REFRESH_TOKEN")
cid = ENV.get("GOOGLE_ADS_CLIENT_ID") or ENV.get("GOOGLE_OAUTH_CLIENT_ID")
secret = ENV.get("GOOGLE_ADS_CLIENT_SECRET") or ENV.get("GOOGLE_OAUTH_CLIENT_SECRET")
body = urllib.parse.urlencode(
{
"refresh_token": refresh,
"client_id": cid,
"client_secret": secret,
"grant_type": "refresh_token",
}
).encode()
req = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["access_token"]
def main() -> int:
tenants = sb_get("/rest/v1/tenants?select=id,name,slug&order=name.asc")
print(f"tenants={len(tenants)}")
# --- tokens ---
g_rows = sb_get(
"/rest/v1/agency_oauth?provider=eq.google_drive&select=access_token,refresh_token,expires_at,account_email,scopes"
)
m_rows = sb_get(
"/rest/v1/agency_oauth?provider=eq.meta_ads&select=access_token,account_email,scopes,updated_at"
)
if not g_rows:
print("FAIL: no google_drive oauth")
return 1
if not m_rows:
print("FAIL: no meta_ads oauth")
return 1
grow = g_rows[0]
mrow = m_rows[0]
print("Google:", grow.get("account_email"))
print("Meta:", mrow.get("account_email"), "updated", mrow.get("updated_at"))
gtoken = grow["access_token"]
exp = grow.get("expires_at")
if exp:
try:
et = datetime.fromisoformat(exp.replace("Z", "+00:00"))
if et.timestamp() < datetime.now(timezone.utc).timestamp() + 60:
print("Refreshing Google access token…")
gtoken = google_refresh(grow["refresh_token"])
except Exception as e:
print("google refresh warn", e)
gtoken = google_refresh(grow["refresh_token"])
mtoken = mrow["access_token"]
now = datetime.now(timezone.utc).isoformat()
linked_counts = defaultdict(int)
# ========== META ==========
print("\n=== META Ads + Pages + IG ===")
ad_accounts = []
for row in meta_paginate(
mtoken, "me/adaccounts?fields=id,account_id,name,account_status&limit=200"
):
aid = row.get("account_id") or row["id"].replace("act_", "")
ad_accounts.append({"id": f"act_{aid}", "name": row.get("name") or aid})
print(f"Meta ad accounts visible: {len(ad_accounts)}")
pages = []
igs = []
for page in meta_paginate(
mtoken,
"me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100",
):
pages.append({"id": page["id"], "name": page.get("name") or page["id"]})
ig = page.get("instagram_business_account") or {}
if ig.get("id"):
igs.append(
{
"id": ig["id"],
"name": f"@{ig['username']}" if ig.get("username") else (ig.get("name") or page.get("name") or ig["id"]),
}
)
print(f"FB pages visible: {len(pages)} · IG: {len(igs)}")
for provider, accounts, label in (
("meta_ads", ad_accounts, "Meta Ads"),
("facebook", pages, "Facebook Page"),
("instagram", igs, "Instagram"),
):
matches = best_matches(tenants, accounts, threshold=55)
for tid, (acc, sc) in matches.items():
ok = sb_upsert_integ(
tid,
provider,
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"enabled": True,
"meta": {
"account_name": acc["name"],
"match_score": sc,
"matched_at": now,
"source": "refresh_script",
},
},
)
if ok:
linked_counts[provider] += 1
print(f" linked {provider}: {linked_counts[provider]}/{len(accounts)}")
# ========== GOOGLE DRIVE ==========
print("\n=== Google Drive folders ===")
folders = []
page_token = None
while True:
params = {
"q": "mimeType='application/vnd.google-apps.folder' and trashed=false",
"fields": "nextPageToken,files(id,name)",
"pageSize": "1000",
"corpora": "allDrives",
"includeItemsFromAllDrives": "true",
"supportsAllDrives": "true",
}
if page_token:
params["pageToken"] = page_token
req = urllib.request.Request(
"https://www.googleapis.com/drive/v3/files?" + urllib.parse.urlencode(params),
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
j = json.loads(r.read().decode())
for f in j.get("files") or []:
folders.append({"id": f["id"], "name": f["name"]})
page_token = j.get("nextPageToken")
if not page_token:
break
print(f"Drive folders: {len(folders)}")
matches = best_matches(tenants, folders, threshold=55)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"google_drive",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {"folder_name": acc["name"], "match_score": sc, "source": "refresh_script"},
},
):
linked_counts["google_drive"] += 1
print(f" linked google_drive: {linked_counts['google_drive']}")
# ========== GA4 ==========
print("\n=== GA4 properties ===")
ga4 = []
req = urllib.request.Request(
"https://analyticsadmin.googleapis.com/v1beta/accountSummaries",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
summaries = json.loads(r.read().decode())
for acc in summaries.get("accountSummaries") or []:
for p in acc.get("propertySummaries") or []:
prop = p.get("property")
if not prop:
continue
# measurement id
mid = None
try:
req2 = urllib.request.Request(
f"https://analyticsadmin.googleapis.com/v1beta/{prop}/dataStreams",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req2, timeout=60) as r2:
streams = json.loads(r2.read().decode())
for s in streams.get("dataStreams") or []:
mid = (s.get("webStreamData") or {}).get("measurementId")
if mid:
break
except Exception:
pass
ga4.append(
{
"id": mid or prop,
"name": p.get("displayName") or prop,
"property_id": prop,
"measurement_id": mid,
}
)
print(f"GA4 properties: {len(ga4)}")
matches = best_matches(tenants, ga4, threshold=50)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"ga4",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {
"property_id": acc.get("property_id"),
"measurement_id": acc.get("measurement_id"),
"display_name": acc["name"],
"match_score": sc,
"source": "refresh_script",
},
},
):
linked_counts["ga4"] += 1
print(f" linked ga4: {linked_counts['ga4']}")
# ========== GSC ==========
print("\n=== Search Console ===")
req = urllib.request.Request(
"https://www.googleapis.com/webmasters/v3/sites",
headers={"Authorization": f"Bearer {gtoken}"},
)
with urllib.request.urlopen(req, timeout=90) as r:
sites_j = json.loads(r.read().decode())
gsc = []
for s in sites_j.get("siteEntry") or []:
url = s.get("siteUrl")
if url:
gsc.append({"id": url, "name": url})
print(f"GSC sites: {len(gsc)}")
matches = best_matches(tenants, gsc, threshold=40)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"search_console",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["id"],
"last_sync_at": now,
"error_message": None,
"meta": {"site_url": acc["id"], "match_score": sc, "source": "refresh_script"},
},
):
linked_counts["gsc"] += 1
print(f" linked gsc: {linked_counts['gsc']}")
# ========== Google Ads ==========
print("\n=== Google Ads ===")
try:
at = gads_access()
headers = {
"Authorization": f"Bearer {at}",
"developer-token": ENV["GOOGLE_ADS_DEVELOPER_TOKEN"],
"Content-Type": "application/json",
}
login = (ENV.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID") or "").replace("-", "")
if login:
headers["login-customer-id"] = login
req = urllib.request.Request(
f"{GADS}/customers:listAccessibleCustomers", headers=headers
)
with urllib.request.urlopen(req, timeout=90) as r:
listed = json.loads(r.read().decode())
ids = [rn.replace("customers/", "") for rn in listed.get("resourceNames") or []]
gads_accounts = []
for cid in ids:
h = dict(headers)
h["login-customer-id"] = cid
body = json.dumps(
{
"query": "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"
}
).encode()
req = urllib.request.Request(
f"{GADS}/customers/{cid}/googleAds:search",
data=body,
headers=h,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
j = json.loads(r.read().decode())
except Exception:
continue
c = ((j.get("results") or [{}])[0]).get("customer") or {}
if not c:
continue
name = c.get("descriptiveName") or cid
if c.get("manager"):
# pull clients
body2 = json.dumps(
{
"query": (
"SELECT customer_client.id, customer_client.descriptive_name, "
"customer_client.manager, customer_client.status FROM customer_client "
"WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE"
)
}
).encode()
req2 = urllib.request.Request(
f"{GADS}/customers/{cid}/googleAds:search",
data=body2,
headers=h,
method="POST",
)
try:
with urllib.request.urlopen(req2, timeout=90) as r2:
j2 = json.loads(r2.read().decode())
for row in j2.get("results") or []:
cc = row.get("customerClient") or {}
if cc.get("id"):
gads_accounts.append(
{
"id": str(cc["id"]),
"name": cc.get("descriptiveName") or str(cc["id"]),
}
)
except Exception:
pass
else:
gads_accounts.append({"id": str(c.get("id") or cid), "name": name})
# dedupe
mmap = {a["id"]: a for a in gads_accounts}
gads_accounts = list(mmap.values())
print(f"Google Ads accounts: {len(gads_accounts)}")
matches = best_matches(tenants, gads_accounts, threshold=55)
for tid, (acc, sc) in matches.items():
if sb_upsert_integ(
tid,
"google_ads",
{
"status": "connected",
"external_account_id": acc["id"],
"label": acc["name"],
"last_sync_at": now,
"error_message": None,
"meta": {
"account_name": acc["name"],
"customer_id": acc["id"],
"match_score": sc,
"source": "refresh_script",
},
},
):
linked_counts["google_ads"] += 1
print(f" linked google_ads: {linked_counts['google_ads']}")
except Exception as e:
print("Google Ads ERROR:", e)
# ========== AUDIT ==========
print("\n=== AUDIT ===")
integs = sb_get(
"/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)"
"&select=tenant_id,provider,status,external_account_id,error_message"
)
by = defaultdict(dict)
for i in integs:
by[i["tenant_id"]][i["provider"]] = i
def flag(i):
if not i:
return "—"
if i.get("status") == "connected" and i.get("external_account_id"):
return "OK"
if i.get("status") == "error":
return "ERR"
return "—"
print(
f"{'Cliente':30} {'GAds':5} {'Meta':5} {'GA4':5} {'GSC':5} {'Drive':5} {'FB':5} {'IG':5} Org"
)
for t in tenants:
m = by[t["id"]]
# organic count
req = urllib.request.Request(
f"{BASE}/rest/v1/organic_daily?tenant_id=eq.{t['id']}&select=day",
headers={**SH, "Prefer": "count=exact", "Range": "0-0"},
)
with urllib.request.urlopen(req, timeout=30) as r:
cr = r.headers.get("content-range") or "*/0"
org = cr.split("/")[-1]
print(
f"{t['name'][:30]:30} "
f"{flag(m.get('google_ads')):5} "
f"{flag(m.get('meta_ads')):5} "
f"{flag(m.get('ga4')):5} "
f"{flag(m.get('search_console')):5} "
f"{flag(m.get('google_drive')):5} "
f"{flag(m.get('facebook')):5} "
f"{flag(m.get('instagram')):5} "
f"{org}"
)
print("\n=== Linked counts this run ===")
print(dict(linked_counts))
print("\nDone. Orgánico days still need app sync (Emparejar todo Google / abrir Orgánico).")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,203 @@
/**
* One-shot: bootstrap Google + discover Meta → fichas, then print audit.
* Usage: npx tsx --env-file=.env.local scripts/refresh_agency_connections.ts
*/
import { bootstrapAgencyGoogle } from '../src/lib/agency-google-bootstrap';
import { discoverAndMatch } from '../src/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
async function get(path: string) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) throw new Error(`${path}: ${await r.text()}`);
return r.json();
}
async function organicCount(tenantId: string): Promise<number> {
const headers = serviceHeaders();
if (!headers) return 0;
const r = await fetch(
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${tenantId}&select=day`,
{
headers: { ...headers, Prefer: 'count=exact', Range: '0-0' },
cache: 'no-store',
}
);
const cr = r.headers.get('content-range') || '';
if (cr.includes('/')) return Number(cr.split('/').pop()) || 0;
return 0;
}
async function main() {
console.log('=== 1. Google bootstrap (Drive + GA4 + GSC + Ads + sync) ===');
const boot = await bootstrapAgencyGoogle({
includeGoogleAds: true,
syncOrganic: true,
organicDays: 28,
});
console.log(JSON.stringify(boot, null, 2));
console.log('\n=== 2. Meta discover (Ads + FB + IG) ===');
const meta = await discoverAndMatch({
apply: true,
providers: ['meta_ads', 'google_ads'],
});
console.log(
JSON.stringify(
{
meta: {
accounts: meta.meta?.accounts,
linked: meta.meta?.linked,
pagesLinked: meta.meta?.pagesLinked,
igLinked: meta.meta?.igLinked,
error: meta.meta?.error,
},
google: {
accounts: meta.google?.accounts,
linked: meta.google?.linked,
error: meta.google?.error,
},
},
null,
2
)
);
console.log('\n=== 3. Audit por cliente ===');
const tenants = (await get(
'/rest/v1/tenants?select=id,name,slug&order=name.asc'
)) as { id: string; name: string; slug: string }[];
const integs = (await get(
'/rest/v1/integrations?provider=in.(ga4,search_console,google_drive,google_ads,meta_ads,facebook,instagram)&select=tenant_id,provider,status,external_account_id,error_message,meta'
)) as {
tenant_id: string;
provider: string;
status: string;
external_account_id?: string | null;
error_message?: string | null;
meta?: Record<string, unknown> | null;
}[];
const by = new Map<string, typeof integs>();
for (const i of integs) {
const arr = by.get(i.tenant_id) || [];
arr.push(i);
by.set(i.tenant_id, arr);
}
const providers = [
'google_ads',
'meta_ads',
'ga4',
'search_console',
'google_drive',
'facebook',
'instagram',
] as const;
type Row = {
name: string;
organic_days: number;
channels: Record<string, string>;
gaps: string[];
};
const rows: Row[] = [];
for (const t of tenants) {
const map = new Map(
(by.get(t.id) || []).map((i) => [i.provider, i] as const)
);
const channels: Record<string, string> = {};
const gaps: string[] = [];
for (const p of providers) {
const i = map.get(p);
if (i?.status === 'connected' && i.external_account_id) {
const id = String(i.external_account_id);
channels[p] = id.length > 42 ? id.slice(0, 40) + '…' : id;
} else if (i?.status === 'error') {
channels[p] = `ERR:${(i.error_message || '').slice(0, 40)}`;
gaps.push(p);
} else {
channels[p] = '—';
gaps.push(p);
}
}
const org = await organicCount(t.id);
rows.push({ name: t.name, organic_days: org, channels, gaps });
}
console.log(
[
'Cliente'.padEnd(28),
'GAds',
'Meta',
'GA4',
'GSC',
'Drive',
'FB',
'IG',
'OrgDías',
].join('\t')
);
for (const r of rows) {
const c = r.channels;
const flag = (v: string) => (v === '—' ? '—' : v.startsWith('ERR') ? 'ERR' : 'OK');
console.log(
[
r.name.slice(0, 28).padEnd(28),
flag(c.google_ads),
flag(c.meta_ads),
flag(c.ga4),
flag(c.search_console),
flag(c.google_drive),
flag(c.facebook),
flag(c.instagram),
String(r.organic_days),
].join('\t')
);
}
console.log('\n=== Detalle IDs (solo conectados) ===');
for (const r of rows) {
const linked = Object.entries(r.channels).filter(([, v]) => v !== '—' && !v.startsWith('ERR'));
if (!linked.length && r.organic_days === 0) continue;
console.log(`\n${r.name} · organic_daily=${r.organic_days}`);
for (const [p, v] of linked) console.log(` ${p}: ${v}`);
}
const withAds = rows.filter(
(r) => r.channels.google_ads !== '—' || r.channels.meta_ads !== '—'
).length;
const withOrg = rows.filter(
(r) =>
r.channels.ga4 !== '—' ||
r.channels.search_console !== '—' ||
r.organic_days > 0
).length;
const withSocial = rows.filter(
(r) => r.channels.facebook !== '—' || r.channels.instagram !== '—'
).length;
console.log('\n=== RESUMEN ===');
console.log(
JSON.stringify(
{
tenants: rows.length,
with_ads: withAds,
with_organic_link_or_data: withOrg,
with_fb_or_ig: withSocial,
organic_tenants_with_rows: rows.filter((r) => r.organic_days > 0).length,
},
null,
2
)
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { generateMonthPreview } from '../src/lib/calendar-generate';
import type { RrssMonthPlan } from '../src/components/RrssMonthView';
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = { apikey: key, Authorization: `Bearer ${key}` };
const tid = '8bec977a-6032-4759-aa7f-b5042081f450';
const r = await fetch(
`${url}/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
{ headers: h }
);
const docs = (await r.json()) as { id: string; content: unknown }[];
const history: RrssMonthPlan[] = [];
let novId: string | null = null;
for (const d of docs) {
const c =
typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
const plan = c as RrssMonthPlan;
if (plan?.month === '2026-11') novId = d.id;
else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
}
const result = await generateMonthPreview({
ym: '2026-11',
slug: 'bps',
label: 'Blue Parrot School',
history,
});
console.log('source', result.source);
console.log('rationale\n', result.rationale);
console.log('pieces', result.plan.pieces.length);
for (const p of result.plan.pieces) {
console.log(p.date, p.format.padEnd(8), '|', p.title);
}
if (!novId) {
console.log('No Nov doc to patch');
return;
}
const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
method: 'PATCH',
headers: {
...h,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
content: result.plan,
title: 'RRSS Noviembre 2026',
updated_at: new Date().toISOString(),
}),
});
console.log('patched', patch.status);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,164 @@
/**
* Carga las 33 piezas de Blue Parrot (oct 2026 - ene 2027) en creative_pipeline.
*
* Requiere:
* 1. Haber aplicado sql/creative_pipeline.sql en Supabase.
* 2. Haber descomprimido Blue-Parrot-creatividades-con-imagen.zip en una carpeta.
* 3. .env.local con NEXT_PUBLIC_SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY reales
* (los mismos que usa la app en producción — ejecutar esto en el propio VPS,
* por ejemplo desde "Open Terminal" en Dokploy, nunca pegando la service
* role key en un chat).
*
* Uso:
* npx tsx scripts/seed_blue_parrot_pipeline.ts --dir /ruta/a/Blue-Parrot-creatividades-con-imagen \
* [--status revision_interna] [--dry-run]
*/
import { config } from 'dotenv';
import { resolve, join } from 'path';
import { existsSync, readFileSync } from 'fs';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
// Mismo tenant_id que scripts/provision_victor_bps.ts
const BLUE_PARROT_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
const BUCKET = 'tenant-assets';
type SeedItem = {
id: string;
format: 'post' | 'story' | 'reel' | 'carrusel';
month: string;
category: string;
title: string;
};
function arg(name: string, fallback?: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
return process.argv[i + 1];
}
const DRY_RUN = process.argv.includes('--dry-run');
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local');
const dir = arg('dir');
if (!dir || !existsSync(dir)) {
throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)');
}
const status = arg('status', 'revision_agencia')!;
const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json');
const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8'));
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
// Asegura el bucket (no falla si ya existe).
const { data: buckets } = await admin.storage.listBuckets();
if (!buckets?.some((b) => b.id === BUCKET)) {
await admin.storage.createBucket(BUCKET, { public: false });
}
let ok = 0;
let failed = 0;
for (const it of items) {
const isCarrusel = it.format === 'carrusel';
const coverLocal = isCarrusel
? join(dir, `${it.id}-carrusel`, `${it.id}-1.jpg`)
: join(dir, `${it.id}-${it.format}.jpg`);
if (!existsSync(coverLocal)) {
console.error(`[SKIP] ${it.id}: no existe ${coverLocal}`);
failed++;
continue;
}
console.log(`[${it.id}] ${it.title}`);
if (DRY_RUN) {
ok++;
continue;
}
// 1) upsert de la fila (sin imágenes todavía) para obtener el id.
const { data: row, error: upErr } = await admin
.from('creative_pipeline')
.upsert(
{
tenant_id: BLUE_PARROT_TENANT_ID,
external_ref: it.id,
title: it.title,
format: it.format,
month: it.month,
status,
notes: it.category,
},
{ onConflict: 'tenant_id,external_ref' }
)
.select('id')
.single();
if (upErr || !row) {
console.error(`[FAIL] ${it.id}: ${upErr?.message}`);
failed++;
continue;
}
const itemId = row.id as string;
// 2) sube portada
const coverPath = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
const coverBuf = readFileSync(coverLocal);
const coverUp = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (coverUp.error) {
console.error(`[FAIL cover] ${it.id}: ${coverUp.error.message}`);
failed++;
continue;
}
// 3) sube slides (carrusel: 2,3,4,5 → 4 slides interiores + CTA)
const slidePaths: string[] = [];
if (isCarrusel) {
for (let n = 2; n <= 5; n++) {
const local = join(dir, `${it.id}-carrusel`, `${it.id}-${n}.jpg`);
if (!existsSync(local)) continue;
const path = `${BLUE_PARROT_TENANT_ID}/pipeline/${itemId}/slide-${n - 1}.jpg`;
const buf = readFileSync(local);
const up = await admin.storage.from(BUCKET).upload(path, buf, {
contentType: 'image/jpeg',
upsert: true,
});
if (up.error) {
console.error(`[FAIL slide ${n}] ${it.id}: ${up.error.message}`);
continue;
}
slidePaths.push(path);
}
}
// 4) cierra la fila con las rutas de storage
const { error: patchErr } = await admin
.from('creative_pipeline')
.update({ cover_path: coverPath, cover_mime: 'image/jpeg', slide_paths: slidePaths })
.eq('id', itemId);
if (patchErr) {
console.error(`[FAIL patch] ${it.id}: ${patchErr.message}`);
failed++;
continue;
}
ok++;
}
console.log(`\nHecho: ${ok} ok, ${failed} con error, de ${items.length} piezas.`);
if (failed > 0) process.exitCode = 1;
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});

View File

@@ -0,0 +1,143 @@
/**
* Seed brandbook for every tenant + regenerate Nov 2026 editorial strategy.
* Usage: npx tsx scripts/seed_brandbooks_regen_nov.ts
* Optional: MONTH=2026-11 FORCE_BRANDBOOK=1
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import type { RrssMonthPlan } from '../src/components/RrssMonthView';
import { generateMonthPreview } from '../src/lib/calendar-generate';
import {
BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE,
parseBrandbook,
seedBrandbook,
} from '../src/lib/brandbook';
import { monthLabel } from '../src/lib/calendar-import';
const ym = process.env.MONTH || '2026-11';
const forceBook = process.env.FORCE_BRANDBOOK === '1';
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
};
const tenants = (await (
await fetch(
`${url}/rest/v1/tenants?select=id,slug,name&order=name.asc`,
{ headers: h }
)
).json()) as { id: string; slug: string; name: string }[];
console.log(`Tenants: ${tenants.length} · month ${ym} · forceBook=${forceBook}`);
for (const t of tenants) {
// --- brandbook ---
const bbRes = await fetch(
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
{ headers: h }
);
const bbRows = (await bbRes.json()) as { id: string; content: unknown }[];
const existing = parseBrandbook(bbRows[0]?.content);
let book = existing;
if (!existing || forceBook) {
book = seedBrandbook(t.slug, t.name);
if (bbRows[0]?.id) {
await fetch(`${url}/rest/v1/documents?id=eq.${bbRows[0].id}`, {
method: 'PATCH',
headers: { ...h, Prefer: 'return=minimal' },
body: JSON.stringify({
content: book,
title: BRANDBOOK_DOC_TITLE,
updated_at: new Date().toISOString(),
}),
});
console.log(`[${t.slug}] brandbook updated`);
} else {
await fetch(`${url}/rest/v1/documents`, {
method: 'POST',
headers: { ...h, Prefer: 'return=minimal' },
body: JSON.stringify({
tenant_id: t.id,
type: BRANDBOOK_DOC_TYPE,
title: BRANDBOOK_DOC_TITLE,
status: 'approved',
content: book,
}),
});
console.log(`[${t.slug}] brandbook created`);
}
} else {
console.log(`[${t.slug}] brandbook exists (skip)`);
}
// --- calendars ---
const calRes = await fetch(
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.calendar&select=id,content&order=created_at.desc&limit=48`,
{ headers: h }
);
const docs = (await calRes.json()) as { id: string; content: unknown }[];
const history: RrssMonthPlan[] = [];
let novId: string | null = null;
for (const d of docs) {
const c =
typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
const plan = c as RrssMonthPlan;
if (plan?.month === ym) novId = d.id;
else if (plan?.month && Array.isArray(plan.pieces)) history.push(plan);
}
const result = await generateMonthPreview({
ym,
slug: t.slug,
label: t.name,
history,
brandbook: book,
});
const title = `RRSS ${monthLabel(ym)}`;
if (novId) {
const patch = await fetch(`${url}/rest/v1/documents?id=eq.${novId}`, {
method: 'PATCH',
headers: { ...h, Prefer: 'return=minimal' },
body: JSON.stringify({
content: result.plan,
title,
updated_at: new Date().toISOString(),
}),
});
console.log(
`[${t.slug}] ${ym} patched ${patch.status} · ${result.plan.pieces.length} pcs · ${result.source}`
);
} else {
const ins = await fetch(`${url}/rest/v1/documents`, {
method: 'POST',
headers: { ...h, Prefer: 'return=minimal' },
body: JSON.stringify({
tenant_id: t.id,
type: 'calendar',
title,
status: 'draft',
content: result.plan,
}),
});
console.log(
`[${t.slug}] ${ym} created ${ins.status} · ${result.plan.pieces.length} pcs · ${result.source}`
);
}
}
console.log('Done.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Authenticated smoke: agencia + simulate."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
SHOT = ROOT / "scripts/dokploy-shots"
def main() -> int:
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
page.goto(f"{APP}/dashboard/agencia", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1500)
SHOT.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(SHOT / "smoke_agencia.png"), full_page=True)
body = page.inner_text("body")
missing_key = "Falta SUPABASE_SERVICE_ROLE_KEY" in body
print("agencia_missing_service_key", missing_key, flush=True)
print("agencia_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][10:22]), flush=True)
sim = page.evaluate(
"""async () => {
const r = await fetch('/api/simulate', {
method: 'POST',
headers: {'content-type':'application/json'},
body: JSON.stringify({ amount: 1000, channel: 'meta' })
});
const text = await r.text();
let json=null; try { json=JSON.parse(text); } catch {}
return { status: r.status, text: text.slice(0,250), json };
}"""
)
print("simulate", json.dumps(sim, ensure_ascii=False)[:400], flush=True)
browser.close()
if missing_key:
print("FAIL_AGENCIA", flush=True)
return 2
print("SUCCESS", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Authenticated chat smoke on app.4ventures.es."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def main() -> int:
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
if "/login" in page.url:
print("LOGIN_FAILED", flush=True)
browser.close()
return 2
# unauth baseline via fresh context later; here authenticated
res = page.evaluate(
"""async () => {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'content-type':'application/json'},
body: JSON.stringify({ message: 'hola' })
});
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { status: r.status, text: text.slice(0, 300), json };
}"""
)
print("auth_chat", json.dumps(res, ensure_ascii=False)[:500], flush=True)
browser.close()
# unauth
import urllib.error
import urllib.request
req = urllib.request.Request(
f"{APP}/api/chat",
data=b'{"message":"hola"}',
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as r:
print("unauth_chat", r.status, r.read()[:80], flush=True)
except urllib.error.HTTPError as e:
print("unauth_chat", e.code, e.read()[:80], flush=True)
ok = res.get("status") == 200 and isinstance(res.get("json"), list)
print("SUCCESS" if ok else "PARTIAL", flush=True)
return 0 if ok else 5
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Create a test creative draft while logged in as marketing@."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
SHOT = ROOT / "scripts/dokploy-shots"
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def load_creds():
email, password = "marketing@4ventures.es", ""
for line in CREDS.read_text().splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
return email, password
def main() -> int:
email, password = load_creds()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(3500)
page.goto(f"{APP}/dashboard/creatives", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1500)
page.locator("input").first.fill("Smoke test · borrador operador")
page.get_by_role("button", name=re.compile(r"Crear borrador", re.I)).click()
page.wait_for_timeout(2500)
page.screenshot(path=str(SHOT / "creatives_after_create.png"), full_page=True)
body = page.inner_text("body")
ok = "Smoke test" in body and "draft" in body.lower()
print("created_visible", ok, flush=True)
if "error" in body.lower() and "Smoke test" not in body:
print("body", body[-400:].replace("\n", " | "), flush=True)
browser.close()
return 2
browser.close()
return 0 if ok else 3
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Login as marketing@ on app.4ventures.es and smoke dashboard routes."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
SHOT = ROOT / "scripts/dokploy-shots"
SHOT.mkdir(parents=True, exist_ok=True)
APP = "https://app.4ventures.es"
CREDS = ROOT / ".ops" / "marketing-credentials.txt"
def load_creds() -> tuple[str, str]:
email = "marketing@4ventures.es"
password = ""
for line in CREDS.read_text(encoding="utf-8").splitlines():
if line.startswith("email="):
email = line.split("=", 1)[1].strip()
if line.startswith("password="):
password = line.split("=", 1)[1].strip()
if not password:
raise SystemExit("missing password in .ops/marketing-credentials.txt")
return email, password
def main() -> int:
email, password = load_creds()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1100})
page.goto(f"{APP}/login", wait_until="networkidle", timeout=90000)
page.wait_for_timeout(1000)
page.screenshot(path=str(SHOT / "app_login.png"), full_page=True)
# Fill login form
email_inp = page.locator('input[type="email"], input[name="email"], input[placeholder*="mail" i]')
pass_inp = page.locator('input[type="password"]')
print("email_inputs", email_inp.count(), "pass_inputs", pass_inp.count(), flush=True)
if not email_inp.count() or not pass_inp.count():
print("LOGIN_FORM_MISSING", flush=True)
print(page.inner_text("body")[:500].replace("\n", " | "), flush=True)
browser.close()
return 2
email_inp.first.fill(email)
pass_inp.first.fill(password)
submit = page.locator('button[type="submit"], button').filter(has_text=re.compile(r"Entrar|Login|Iniciar|Acceder|Sign", re.I))
if submit.count():
submit.first.click()
else:
page.locator('button[type="submit"]').first.click()
page.wait_for_timeout(4000)
page.screenshot(path=str(SHOT / "app_after_login.png"), full_page=True)
print("url", page.url, flush=True)
body = page.inner_text("body")
bad = bool(re.search(r"invalid|incorrect|error|credencial", body, re.I)) and "/login" in page.url
print("still_login", "/login" in page.url, "errorish", bad, flush=True)
print("title_snip", " | ".join([ln.strip() for ln in body.splitlines() if ln.strip()][:12]), flush=True)
if "/login" in page.url:
browser.close()
print("LOGIN_FAILED", flush=True)
return 3
# Smoke routes
results = {}
for path in ("/dashboard", "/dashboard/agencia", "/dashboard/creatives", "/dashboard/kpis"):
page.goto(f"{APP}{path}", wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1200)
code_ish = page.url
txt = page.inner_text("body")[:300].replace("\n", " | ")
results[path] = {"url": code_ish, "snip": txt}
safe = path.strip("/").replace("/", "_") or "root"
page.screenshot(path=str(SHOT / f"app_{safe}.png"), full_page=True)
print(path, "->", code_ish, flush=True)
print(" ", txt[:180], flush=True)
# API with cookies from context
cookies = page.context.cookies()
cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookies if "app.4ventures.es" in c.get("domain", "") or c.get("domain", "").endswith("4ventures.es"))
print("cookies", len(cookies), flush=True)
browser.close()
# Outside: health still ok
import urllib.request
with urllib.request.urlopen("https://app.4ventures.es/api/health", timeout=12) as r:
print("health", r.read().decode()[:180], flush=True)
print("SUCCESS_LOGIN", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

75
scripts/sync_ads_only.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Sync Meta + Google Ads → ad_daily (+ kpi_weekly rollup).
*
* Usage:
* npx tsx scripts/sync_ads_only.ts # last 30d all tenants
* DAYS=640 npx tsx scripts/sync_ads_only.ts # backfill ~from Jan 2025
* TENANT_SLUG=bps DAYS=640 npx tsx ...
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
ADS_BACKFILL_MAX_DAYS,
syncAdsForTenants,
} from '../src/lib/ads-sync';
async function main() {
const slug = process.env.TENANT_SLUG?.trim();
let tenantId: string | undefined;
if (slug) {
const { serviceHeaders, supabaseUrl } = await import('../src/lib/agency');
const headers = serviceHeaders()!;
const r = await fetch(
`${supabaseUrl()}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
{ headers }
);
const rows = (await r.json()) as { id: string }[];
tenantId = rows[0]?.id;
if (!tenantId) throw new Error(`Tenant slug not found: ${slug}`);
}
// Days since 2025-01-01 (inclusive) when DAYS unset and BACKFILL=1
const backfill = process.env.BACKFILL === '1' || process.env.BACKFILL === 'true';
const daysFrom2025 = Math.ceil(
(Date.now() - Date.UTC(2025, 0, 1)) / 86400000
);
const rawDays = Number(process.env.DAYS);
const days = Math.min(
ADS_BACKFILL_MAX_DAYS,
Math.max(
1,
Number.isFinite(rawDays) && rawDays > 0
? rawDays
: backfill
? daysFrom2025
: 30
)
);
console.log(
`Ads sync · days=${days} · since≈${new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)}` +
(tenantId ? ` · tenant=${slug || tenantId}` : ' · all tenants')
);
const { results } = await syncAdsForTenants({ tenantId, days });
let ok = 0;
let err = 0;
for (const x of results) {
const rows = x.metaRows + x.googleRows;
if (rows > 0) ok += 1;
if (x.errors.length) err += 1;
console.log(
`${x.tenantName}: meta=${x.metaRows} google=${x.googleRows} weekly=${x.weeklyRows}` +
(x.errors.length
? ` · ERR ${x.errors.join('; ').slice(0, 200)}`
: '')
);
}
console.log(`\nDone · synced=${ok} withErrors=${err} / ${results.length}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

272
scripts/sync_all_data.ts Normal file
View File

@@ -0,0 +1,272 @@
/**
* One-shot sync: organic + ads + superhuman + SEO audits.
* Usage:
* npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts
* Optional: TENANT_SLUG=bps
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
config({ path: resolve(process.cwd(), '.env') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import { syncAllConnectedOrganic } from '../src/lib/google-organic';
import { syncAdsForTenants } from '../src/lib/ads-sync';
import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs';
import { runSeoSiteAudit } from '../src/lib/seo-audit';
import { resolveMetaAccessTokens } from '../src/lib/ads-discovery';
const META = 'https://graph.facebook.com/v21.0';
type Tenant = { id: string; name: string; slug: string };
async function sbGet<T>(path: string): Promise<T> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`);
return (await r.json()) as T;
}
async function sbPatch(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`);
}
async function sbPost(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`);
}
async function syncSocial(tenants: Tenant[]) {
const tokens = await resolveMetaAccessTokens();
const token = tokens[0];
if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] };
const results: { tenant: string; fb?: string; ig?: string }[] = [];
for (const tenant of tenants) {
const integs = await sbGet<
{
provider: string;
external_account_id?: string | null;
status: string;
meta?: Record<string, unknown> | null;
}[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta`
);
if (!integs.length) continue;
const out: { tenant: string; fb?: string; ig?: string } = {
tenant: tenant.name,
};
for (const i of integs) {
const id = i.external_account_id;
if (!id) continue;
try {
let payload: Record<string, unknown> | null = null;
if (i.provider === 'facebook') {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.fb = 'ok';
} else {
out.fb = `err ${(await r.text()).slice(0, 80)}`;
}
} else {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.ig = 'ok';
} else {
out.ig = `err ${(await r.text()).slice(0, 80)}`;
}
}
if (payload) {
const now = new Date().toISOString();
await sbPatch(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`,
{
last_sync_at: now,
error_message: null,
meta: {
...(i.meta || {}),
social_cache: payload,
social_cached_at: now,
},
}
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'err';
if (i.provider === 'facebook') out.fb = msg;
else out.ig = msg;
}
}
results.push(out);
}
return { ok: true as const, results };
}
async function syncSeoAudits(tenants: Tenant[]) {
const results: {
tenant: string;
score?: number;
grade?: string;
error?: string;
}[] = [];
for (const tenant of tenants) {
const [gsc, ga4] = await Promise.all([
sbGet<
{ external_account_id?: string | null; meta?: { site_url?: string } }[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1`
),
sbGet<{ id: string }[]>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1`
),
]);
const site =
gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null;
if (!site) continue;
try {
const audit = await runSeoSiteAudit({
gscSiteUrl: site,
ga4Connected: ga4.length > 0,
gscConnected: true,
});
await sbPost('seo_audits', {
tenant_id: tenant.id,
site_url: audit.siteUrl,
score: audit.score,
grade: audit.grade,
warning_count: audit.warningCount,
critical_count: audit.criticalCount,
checks: audit.checks,
summary: audit.summary,
});
results.push({
tenant: tenant.name,
score: audit.score,
grade: audit.grade,
});
} catch (e) {
results.push({
tenant: tenant.name,
error: e instanceof Error ? e.message.slice(0, 160) : 'audit error',
});
}
}
return results;
}
async function main() {
if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY');
let tenants = await sbGet<Tenant[]>(
'tenants?select=id,name,slug&order=name.asc'
);
const slug = process.env.TENANT_SLUG?.trim();
const tid = process.env.TENANT_ID?.trim();
if (slug) tenants = tenants.filter((t) => t.slug === slug);
if (tid) tenants = tenants.filter((t) => t.id === tid);
console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`);
console.log('--- ORGANIC ---');
const organic = await syncAllConnectedOrganic({
tenantId: tenants.length === 1 ? tenants[0].id : null,
days: 28,
});
for (const r of organic.results) {
const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId;
console.log(
` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}`
);
}
console.log('\n--- ADS ---');
const ads = await syncAdsForTenants({
tenantId: tenants.length === 1 ? tenants[0].id : undefined,
days: 30,
});
for (const r of ads.results) {
console.log(
` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` +
(r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '')
);
}
console.log('\n--- SUPERHUMAN ---');
try {
const sh = await syncAllSuperhumanTenants({
tenantId: tenants.length === 1 ? tenants[0].id : null,
maxEntries: 100,
exportBodies: true,
});
for (const r of sh.results) {
console.log(
` ${r.tenantName}: ${r.status}` +
(r.upserted ? ` · ${r.upserted}` : '') +
(r.docName ? ` · ${r.docName}` : '') +
(r.error ? ` · ${r.error.slice(0, 80)}` : '')
);
}
} catch (e) {
console.log(' ', e instanceof Error ? e.message : e);
}
console.log('\n--- SOCIAL ---');
const social = await syncSocial(tenants);
if (!social.ok) console.log(' ', social.warning);
for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) {
console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`);
}
console.log('\n--- SEO AUDIT ---');
for (const r of await syncSeoAudits(tenants)) {
console.log(
` ${r.tenant}:` +
(r.grade != null ? ` ${r.grade} (${r.score})` : '') +
(r.error ? ` ERR ${r.error}` : '')
);
}
console.log('\n=== DONE ===\n');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,145 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
exportGoogleDocPlain,
exportGoogleDocHtml,
upsertSeguimientoRows,
patchSuperhumanIntegration,
} from '../src/lib/superhuman-docs';
import { getValidAccessToken } from '../src/lib/google-drive';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const FILE = '1uDnRPWf1Dqx5b9L3TuhfvutyZpSaifz7YFNrRy_eBhw';
const DOC = '7TGywaNiUY';
function parseDayLabel(raw: string): { dateIso: string; label: string } | null {
const m = raw.trim().match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})$/);
if (!m) return null;
let y = Number(m[3]);
const mo = Number(m[2]);
const d = Number(m[1]);
if (y < 100) y += 2000;
if (y < 2000 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return {
dateIso: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
label: `${d}/${mo}/${String(y).slice(2)}`,
};
}
function splitNotesByDay(text: string) {
const clean = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
const dayRe =
/^(?:#{1,4}\s*)?(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{2,4})\s*$/;
const lines = clean.split('\n');
const hits: { idx: number; label: string; dateIso: string }[] = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].trim().match(dayRe);
if (!m) continue;
const parsed = parseDayLabel(`${m[1]}/${m[2]}/${m[3]}`);
if (!parsed) continue;
hits.push({ idx: i, ...parsed });
}
if (!hits.length) return [] as { dateIso: string; label: string; body: string }[];
const sections: { dateIso: string; label: string; body: string }[] = [];
for (let h = 0; h < hits.length; h++) {
const start = hits[h].idx + 1;
const end = h + 1 < hits.length ? hits[h + 1].idx : lines.length;
const body = lines
.slice(start, end)
.join('\n')
.replace(/^\n+/, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!body) continue;
sections.push({ dateIso: hits[h].dateIso, label: hits[h].label, body });
}
sections.sort((a, b) => b.dateIso.localeCompare(a.dateIso));
return sections;
}
async function exportViaDownload(fileId: string, mime: string) {
const token = await getValidAccessToken();
const url = `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/export?mimeType=${encodeURIComponent(mime)}`;
const r = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`${mime} ${r.status}: ${(await r.text()).slice(0, 200)}`);
return await r.text();
}
async function main() {
let plain = '';
try {
plain = await exportGoogleDocPlain(FILE);
console.log('plain via helper', plain.length);
} catch (e) {
console.log('plain helper fail', e instanceof Error ? e.message : e);
}
if (!plain) {
try {
plain = await exportViaDownload(FILE, 'text/plain');
console.log('plain via download', plain.length);
} catch (e) {
console.log('plain download fail', e instanceof Error ? e.message : e);
}
}
try {
const html = await exportGoogleDocHtml(FILE);
console.log('html ok', html.length);
} catch (e) {
console.log('html fail', e instanceof Error ? e.message : e);
}
if (!plain) throw new Error('no plain export');
console.log('--- sample ---\n', plain.slice(0, 2000));
const secs = splitNotesByDay(plain);
console.log('sections', secs.length, secs.slice(0, 8).map((s) => s.label));
const now = new Date().toISOString();
const rows = secs.slice(0, 120).map((sec) => {
const firstLine =
sec.body
.split('\n')
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
.find((l) => l.length > 8) || '';
return {
tenant_id: BPS,
source: 'superhuman' as const,
entry_type: 'meeting' as const,
external_id: `gdoc:${FILE}:${sec.dateIso}`,
title: `Seguimiento · ${sec.label}`,
summary: firstLine.slice(0, 220) || null,
body_md: `<pre>${sec.body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</pre>`,
browser_link: `https://docs.google.com/document/d/${FILE}/edit`,
metrics: {},
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
synced_at: now,
updated_at: now,
};
});
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: now,
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
gdoc_id: FILE,
gdoc_days: n,
synced_at: now,
},
});
console.log('upserted day notes', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,292 @@
/**
* Extrae todas las publicaciones de Instagram de Blue Parrot School
* (Enero 2026 a Septiembre 2026) vía Meta Graph API:
* 1. Descarga portadas y slides de carruseles.
* 2. Sube los archivos a Supabase Storage (tenant-assets).
* 3. Inserta/actualiza las piezas en public.creative_pipeline (status: 'publicado').
* 4. Actualiza/sincroniza en public.documents (type: 'calendar') para reflejarlas en el Calendario.
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
const BPS_IG_ID = '17841431459801731';
const BUCKET = 'tenant-assets';
const META_TOKEN = process.env.META_ACCESS_TOKEN || '';
type IgMedia = {
id: string;
caption?: string;
media_type: 'IMAGE' | 'VIDEO' | 'CAROUSEL_ALBUM';
media_url?: string;
thumbnail_url?: string;
permalink?: string;
timestamp: string;
children?: {
data: Array<{
id: string;
media_type: string;
media_url: string;
}>;
};
};
function cleanTitle(caption?: string, date?: string): string {
if (!caption) return `Publicación Instagram ${date || ''}`.trim();
const firstLine = caption.split('\n')[0].trim();
// Quitar hashtags al inicio si los hay
const clean = firstLine.replace(/#\S+/g, '').trim();
if (clean.length > 5) {
return clean.slice(0, 100);
}
return caption.slice(0, 100).trim() || `Publicación ${date || ''}`;
}
async function fetchBuffer(url: string): Promise<Buffer | null> {
try {
const res = await fetch(url);
if (!res.ok) return null;
const arrayBuffer = await res.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (err) {
console.error(`Error descargando ${url}:`, err);
return null;
}
}
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY');
const admin = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } });
console.log('1. Consultando Meta Graph API para Blue Parrot School (@blueparrotschool)...');
const allMedia: IgMedia[] = [];
let nextUrl: string | null =
`https://graph.facebook.com/v21.0/${BPS_IG_ID}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,children{id,media_type,media_url}&limit=50&access_token=${META_TOKEN}`;
while (nextUrl) {
const res = await fetch(nextUrl);
if (!res.ok) {
const err = await res.text();
throw new Error(`Graph API error: ${err}`);
}
const data = await res.json();
const items: IgMedia[] = data.data || [];
if (!items.length) break;
let reachedPast = false;
for (const it of items) {
const date = it.timestamp.slice(0, 10);
if (date >= '2026-01-01' && date <= '2026-09-30') {
allMedia.push(it);
} else if (date < '2026-01-01') {
reachedPast = true;
break;
}
}
if (reachedPast) break;
nextUrl = data.paging?.next || null;
}
console.log(`Encontradas ${allMedia.length} publicaciones en Instagram entre 2026-01 y 2026-09.`);
// Ordenar cronológicamente (antiguo a nuevo)
allMedia.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
// 2. Subir a Supabase Storage y a public.creative_pipeline
console.log('2. Subiendo imágenes a Storage y registrando en public.creative_pipeline...');
let successCount = 0;
for (let i = 0; i < allMedia.length; i++) {
const it = allMedia[i];
const date = it.timestamp.slice(0, 10);
const month = date.slice(0, 7);
const isCarousel = it.media_type === 'CAROUSEL_ALBUM';
const isReel = it.media_type === 'VIDEO';
const format = isCarousel ? 'carrusel' : isReel ? 'reel' : 'post';
const title = cleanTitle(it.caption, date);
// Obtener URL de portada
const coverUrl = isReel
? (it.thumbnail_url || it.media_url)
: isCarousel
? (it.children?.data?.[0]?.media_url || it.media_url)
: it.media_url;
const extRef = `ig-${it.id}`;
// 1) Upsert fila base en creative_pipeline
const { data: row, error: upErr } = await admin
.from('creative_pipeline')
.upsert(
{
tenant_id: BPS_TENANT_ID,
external_ref: extRef,
title,
format,
month,
status: 'publicado',
caption: it.caption || null,
notes: it.permalink ? `Instagram: ${it.permalink}` : null,
created_at: it.timestamp,
},
{ onConflict: 'tenant_id,external_ref' }
)
.select('id')
.single();
if (upErr || !row) {
console.error(`[ERROR upsert] ${extRef}: ${upErr?.message}`);
continue;
}
const itemId = row.id as string;
// 2) Descargar y subir portada
let coverPath: string | null = null;
if (coverUrl) {
const coverBuf = await fetchBuffer(coverUrl);
if (coverBuf) {
coverPath = `${BPS_TENANT_ID}/pipeline/${itemId}/cover.jpg`;
const upRes = await admin.storage.from(BUCKET).upload(coverPath, coverBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (upRes.error) {
console.error(`[WARN cover upload] ${extRef}: ${upRes.error.message}`);
coverPath = null;
}
}
}
// 3) Si es carrusel, descargar y subir slides
const slidePaths: string[] = [];
if (isCarousel && it.children?.data) {
const slides = it.children.data.slice(1); // slides posteriores a la portada
for (let sIdx = 0; sIdx < slides.length; sIdx++) {
const slide = slides[sIdx];
if (!slide.media_url) continue;
const slideBuf = await fetchBuffer(slide.media_url);
if (slideBuf) {
const sPath = `${BPS_TENANT_ID}/pipeline/${itemId}/slide-${sIdx + 1}.jpg`;
const sUp = await admin.storage.from(BUCKET).upload(sPath, slideBuf, {
contentType: 'image/jpeg',
upsert: true,
});
if (!sUp.error) {
slidePaths.push(sPath);
}
}
}
}
// 4) Actualizar registro con las rutas de storage
await admin
.from('creative_pipeline')
.update({
cover_path: coverPath,
cover_mime: coverPath ? 'image/jpeg' : null,
slide_paths: slidePaths,
})
.eq('id', itemId);
successCount++;
console.log(`[${i + 1}/${allMedia.length}] ${month} [${format}] ${title} (${slidePaths.length} slides)`);
}
console.log(`\nCreatividades cargadas en creative_pipeline: ${successCount} de ${allMedia.length}.`);
// 3. Sincronizar en public.documents (type = 'calendar')
console.log('\n3. Sincronizando con public.documents para el Calendario...');
// Agrupar las publicaciones de Instagram por mes
const igByMonth = new Map<string, IgMedia[]>();
for (const m of allMedia) {
const ym = m.timestamp.slice(0, 7);
const list = igByMonth.get(ym) || [];
list.push(m);
igByMonth.set(ym, list);
}
const { data: allDocs } = await admin
.from('documents')
.select('id, title, content')
.eq('tenant_id', BPS_TENANT_ID)
.eq('type', 'calendar');
const docsList = allDocs || [];
for (const [ym, igList] of igByMonth.entries()) {
// Buscar documento del mes
const doc = docsList.find((d) => {
try {
const c = typeof d.content === 'string' ? JSON.parse(d.content) : d.content;
return c?.month === ym;
} catch {
return false;
}
});
if (doc) {
let content = typeof doc.content === 'string' ? JSON.parse(doc.content) : doc.content;
const pieces: any[] = content?.pieces || [];
// Mapear posts de IG en piezas de calendario si no están
for (const ig of igList) {
const date = ig.timestamp.slice(0, 10);
const title = cleanTitle(ig.caption, date);
const isReel = ig.media_type === 'VIDEO';
const isCarousel = ig.media_type === 'CAROUSEL_ALBUM';
const format = isCarousel ? 'Carrusel' : isReel ? 'Reel' : 'Post';
// Buscar si ya existe una pieza en esa fecha
const match = pieces.find(
(p) => p.date === date && (p.title?.toLowerCase().includes(title.slice(0, 15).toLowerCase()) || p.platform === 'Instagram')
);
if (match) {
match.status = 'published';
match.meta_line = `Instagram: ${ig.permalink || ''}`;
if (!match.body && ig.caption) match.body = ig.caption;
} else {
// Agregar nueva pieza publicada
pieces.push({
id: `bps-${date}-ig-${ig.id}`,
date,
platform: 'Instagram',
format,
pillar: isReel ? 'reels' : isCarousel ? 'carrusel' : 'feed',
title,
body: ig.caption || undefined,
status: 'published',
kind: isReel ? 'reel' : 'post',
color: isReel ? '#7C66B8' : isCarousel ? '#2071cf' : '#0F9D8F',
meta_line: `Instagram: ${ig.permalink || ''}`,
});
}
}
// Ordenar piezas por fecha
pieces.sort((a, b) => a.date.localeCompare(b.date));
content.pieces = pieces;
await admin
.from('documents')
.update({ content })
.eq('id', doc.id);
console.log(`Mes ${ym} actualizado en Calendario: ${pieces.length} piezas.`);
}
}
console.log('\nSincronización completada con éxito.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,59 @@
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import {
buildSeguimientoRowsForDoc,
upsertSeguimientoRows,
patchSuperhumanIntegration,
findSeguimientoGoogleDoc,
} from '../src/lib/superhuman-docs';
const BPS = '8bec977a-6032-4759-aa7f-b5042081f450';
const DOC = '7TGywaNiUY';
async function main() {
const hit = await findSeguimientoGoogleDoc([
'Seguimiento BPS',
'Seguimiento Semanal BPS',
'Seguimiento Blue Parrot School',
'Seguimiento Semanal Blue Parrot School',
]);
console.log('drive hit', hit);
const rows = await buildSeguimientoRowsForDoc({
tenantId: BPS,
tenantName: 'BPS',
docId: DOC,
maxEntries: 120,
exportBodies: true,
});
const gdoc = rows.filter((r) => r.external_id.startsWith('gdoc:'));
console.log('total', rows.length, 'gdoc days', gdoc.length);
console.log(
gdoc.slice(0, 5).map((r) => ({
title: r.title,
day: r.occurred_at,
len: (r.body_md || '').length,
}))
);
const n = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(BPS, {
status: 'connected',
external_account_id: DOC,
label: '01.BLUE PARROT SCHOOL',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: '01.BLUE PARROT SCHOOL',
entries: n,
gdoc_days: gdoc.length,
synced_at: new Date().toISOString(),
},
});
console.log('upserted', n);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Sync Clientify contacts → leads and deals → deals for one tenant.
Requires in .env.local:
CLIENTIFY_API_KEY (or CLIENTIFY_API_KEY_<SLUG>)
NEXT_PUBLIC_SUPABASE_URL
SUPABASE_SERVICE_ROLE_KEY
Usage:
python3 scripts/sync_clientify_tenant.py --tenant-id a7a6eb49-... --label agua-miia
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CFY = "https://api.clientify.com/v1"
# Clientify deal status_desc → warehouse stage
STAGE_MAP = {
"won": "won",
"lost": "lost",
"open": "open",
}
def load_env() -> None:
for line in (ROOT / ".env.local").read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
k = k.strip()
if k and k not in os.environ:
os.environ[k] = v.strip().strip('"').strip("'")
def env_key(label: str) -> str:
slug = label.upper().replace("-", "_").replace(" ", "_")
return (
os.environ.get(f"CLIENTIFY_API_KEY_{slug}")
or os.environ.get("CLIENTIFY_API_KEY")
or ""
)
def sb_headers() -> dict[str, str]:
key = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
return {
"apikey": key,
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
def sb(method: str, path: str, body: Any = None, prefer: str = "return=minimal"):
url = os.environ["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") + path
headers = dict(sb_headers())
if prefer:
headers["Prefer"] = prefer
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw.strip() else None)
except urllib.error.HTTPError as e:
return e.code, e.read().decode()[:500]
def cfy_get(token: str, path: str) -> dict:
req = urllib.request.Request(
CFY + path,
headers={"Authorization": f"Token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
def cfy_paginate(token: str, path: str, page_size: int = 100) -> list[dict]:
out: list[dict] = []
# path may already have query
sep = "&" if "?" in path else "?"
url_path = f"{path}{sep}page_size={page_size}"
page = 1
while True:
q = f"{url_path}&page={page}" if "page=" not in url_path else url_path
# rebuild clean
base, _, query = path.partition("?")
params = urllib.parse.parse_qs(query)
params["page_size"] = [str(page_size)]
params["page"] = [str(page)]
q = base + "?" + urllib.parse.urlencode({k: v[0] for k, v in params.items()})
j = cfy_get(token, q)
batch = j.get("results") or []
out.extend(batch)
print(f" {path} page={page} +{len(batch)} total={len(out)}/{j.get('count')}", flush=True)
if not j.get("next") or not batch:
break
page += 1
return out
def first_email(c: dict) -> str | None:
emails = c.get("emails") or []
if isinstance(emails, list) and emails:
e = emails[0]
if isinstance(e, dict):
return (e.get("email") or e.get("value") or "").strip() or None
return str(e).strip() or None
return None
def first_phone(c: dict) -> str | None:
phones = c.get("phones") or []
if isinstance(phones, list) and phones:
p = phones[0]
if isinstance(p, dict):
return (p.get("phone") or p.get("value") or "").strip() or None
return str(p).strip() or None
return None
def contact_id_from_url(url: str | None) -> str | None:
if not url:
return None
# https://api.clientify.com/v1/contacts/12345/
parts = url.rstrip("/").split("/")
return parts[-1] if parts else None
def map_stage(deal: dict) -> str:
desc = (deal.get("status_desc") or "").strip().lower()
if desc in STAGE_MAP:
return STAGE_MAP[desc]
st = deal.get("status")
# Observed: 1 open-ish, 3 won, 4 lost (API filter unreliable)
if st == 3 or desc == "won":
return "won"
if st == 4 or desc == "lost":
return "lost"
return "open"
def parse_date(s: str | None) -> str | None:
if not s:
return None
# date-only or iso
try:
if "T" in s:
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat()
return datetime.fromisoformat(s + "T12:00:00+00:00").astimezone(timezone.utc).isoformat()
except Exception:
return None
def main() -> int:
load_env()
ap = argparse.ArgumentParser()
ap.add_argument("--tenant-id", required=True)
ap.add_argument("--label", default="tenant")
ap.add_argument("--contacts-only", action="store_true")
ap.add_argument("--deals-only", action="store_true")
args = ap.parse_args()
token = env_key(args.label)
if not token:
print("missing CLIENTIFY_API_KEY", file=sys.stderr)
return 2
tid = args.tenant_id
print(f"Sync Clientify → warehouse tenant={tid} label={args.label}", flush=True)
do_contacts = not args.deals_only
do_deals = not args.contacts_only
contact_to_lead: dict[str, str] = {}
if do_contacts:
print("Fetching contacts…", flush=True)
contacts = cfy_paginate(token, "/contacts/")
# wipe previous clientify leads for tenant
st, _ = sb(
"DELETE",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify",
)
print(f"DELETE leads → {st}", flush=True)
rows = []
for c in contacts:
cid = str(c["id"])
lid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-lead:{tid}:{cid}"))
contact_to_lead[cid] = lid
source = (
(c.get("contact_source") or c.get("channel") or c.get("medium") or "clientify")
)
if isinstance(source, str):
source = source.strip().lower()[:80] or "clientify"
else:
source = "clientify"
status = (c.get("status") or "new")
if isinstance(status, str):
status = status.strip().lower()[:40] or "new"
else:
status = "new"
rows.append(
{
"id": lid,
"tenant_id": tid,
"first_name": (
" ".join(
x
for x in [(c.get("first_name") or "").strip(), (c.get("last_name") or "").strip()]
if x
)
or None
),
"email": first_email(c),
"phone": first_phone(c),
"source": source,
"medium": (c.get("medium") or None),
"crm": "clientify",
"crm_contact_id": cid,
"status": status,
"created_at": parse_date(c.get("created"))
or datetime.now(timezone.utc).isoformat(),
}
)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/leads", chunk)
if st not in (200, 201):
print("FAIL leads", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT leads {ok}/{len(rows)}", flush=True)
print(f"OK contacts→leads {ok}", flush=True)
if do_deals:
if not contact_to_lead and do_contacts is False:
# load existing map
st, data = sb(
"GET",
f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify&select=id,crm_contact_id&limit=20000",
prefer="",
)
if st == 200 and isinstance(data, list):
for r in data:
if r.get("crm_contact_id"):
contact_to_lead[str(r["crm_contact_id"])] = r["id"]
print("Fetching deals…", flush=True)
deals = cfy_paginate(token, "/deals/")
st, _ = sb(
"DELETE",
f"/rest/v1/deals?tenant_id=eq.{tid}&crm_deal_id=like.clientify:*",
)
print(f"DELETE deals → {st}", flush=True)
rows = []
by_stage = {"won": 0, "lost": 0, "open": 0}
for d in deals:
did = str(d["id"])
stage = map_stage(d)
by_stage[stage] = by_stage.get(stage, 0) + 1
cid = contact_id_from_url(d.get("contact"))
lead_id = contact_to_lead.get(cid) if cid else None
amount = float(d.get("amount") or d.get("amount_user") or 0)
closed = parse_date(d.get("actual_closed_date"))
pipe_url = str(d.get("pipeline") or "")
pipe_id = ""
if "/pipelines/" in pipe_url:
pipe_id = pipe_url.rstrip("/").split("/")[-1]
raw_src = (
d.get("deal_source")
or d.get("contact_source")
or ""
)
if isinstance(raw_src, str):
raw_src = raw_src.strip()[:80]
else:
raw_src = ""
tags = d.get("tags") or []
tag_blob = " ".join(str(t) for t in tags).lower() if isinstance(tags, list) else ""
name = (d.get("name") or "")[:200]
if not raw_src:
if any(x in tag_blob for x in ("facebook", "instagram", "meta", " fb ")):
raw_src = "meta"
elif "google" in tag_blob:
raw_src = "google"
elif "woocommerce" in tag_blob or name.lower().startswith("order "):
raw_src = "ecommerce"
row = {
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")),
"tenant_id": tid,
"lead_id": lead_id,
"crm_deal_id": f"clientify:{did}",
"stage": stage,
"amount": amount,
"won_at": closed if stage == "won" else None,
"lost_at": closed if stage == "lost" else None,
"pipeline_id": pipe_id or None,
"pipeline_name": (d.get("pipeline_desc") or None),
"source": raw_src.lower() if raw_src else None,
"name": name or None,
}
rows.append(row)
ok = 0
for i in range(0, len(rows), 200):
chunk = rows[i : i + 200]
st, err = sb("POST", "/rest/v1/deals", chunk)
if st not in (200, 201):
print("FAIL deals", st, err, flush=True)
return 1
ok += len(chunk)
print(f" INSERT deals {ok}/{len(rows)}", flush=True)
won_amt = sum(r["amount"] for r in rows if r["stage"] == "won")
print(f"OK deals {ok} by_stage={by_stage} won_amount={won_amt:.2f}", flush=True)
# touch integration
now = datetime.now(timezone.utc).isoformat()
sb(
"PATCH",
f"/rest/v1/integrations?tenant_id=eq.{tid}&provider=eq.clientify",
{"status": "connected", "last_sync_at": now, "error_message": None},
)
print("DONE", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

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,5 @@
-- Store fractional Google Ads conversions so period totals match Ads Manager
-- (Math.round of the exact sum) for any date range.
ALTER TABLE public.ad_daily
ALTER COLUMN platform_leads TYPE numeric(14, 6)
USING platform_leads::numeric(14, 6);

View File

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

16
sql/agency_control.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Agency control: follow/archive clients + enable/disable channels
-- Idempotent.
alter table public.tenants
add column if not exists agency_followed boolean not null default true;
alter table public.tenants
add column if not exists agency_notes text;
alter table public.integrations
add column if not exists enabled boolean not null default true;
comment on column public.tenants.agency_followed is
'Agency portfolio: false = archived / not actively managed in Centro de Mando';
comment on column public.integrations.enabled is
'Agency wants this channel for the client (false = deliberately off)';

116
sql/agency_portfolio.sql Normal file
View File

@@ -0,0 +1,116 @@
-- Agency portfolio: integrations + alerts per tenant
-- Idempotent. Run as postgres / service role.
create table if not exists public.integrations (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
provider text not null
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp'
)),
status text not null default 'disconnected'
check (status in ('disconnected', 'pending', 'connected', 'error')),
external_account_id text,
label text,
last_sync_at timestamptz,
error_message text,
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, provider)
);
create index if not exists integrations_tenant_idx
on public.integrations (tenant_id, provider);
alter table public.integrations enable row level security;
drop policy if exists integrations_tenant_select on public.integrations;
drop policy if exists integrations_tenant_write on public.integrations;
-- Clients see only their tenant; agency uses service role for cross-tenant.
create policy integrations_tenant_select on public.integrations
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy integrations_tenant_write on public.integrations
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create table if not exists public.agency_alerts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid references public.tenants(id) on delete cascade,
severity text not null default 'info'
check (severity in ('info', 'warning', 'critical')),
source text not null default 'system',
title text not null,
body text,
href text,
read_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists agency_alerts_unread_idx
on public.agency_alerts (created_at desc)
where read_at is null;
alter table public.agency_alerts enable row level security;
drop policy if exists agency_alerts_tenant_select on public.agency_alerts;
create policy agency_alerts_tenant_select on public.agency_alerts
for select using (
tenant_id is null
or tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Seed disconnected integration slots for every existing tenant
insert into public.integrations (tenant_id, provider, status, label)
select t.id, p.provider, 'disconnected', p.label
from public.tenants t
cross join (
values
('meta_ads', 'Meta Ads'),
('google_ads', 'Google Ads (MCC)'),
('ga4', 'Google Analytics 4'),
('search_console', 'Search Console'),
('tag_manager', 'Tag Manager'),
('clientify', 'Clientify'),
('klaviyo', 'Klaviyo'),
('mailchimp', 'Mailchimp')
) as p(provider, label)
on conflict (tenant_id, provider) do nothing;
-- Bootstrap alert so the UI isn't empty on day one
insert into public.agency_alerts (tenant_id, severity, source, title, body, href)
select null, 'info', 'system',
'Portafolio agencia activo',
'Selector de cliente + fichas de integración listas. Conecta Meta/MCC/GA4 por tenant.',
'/dashboard/agencia'
where not exists (
select 1 from public.agency_alerts where title = 'Portafolio agencia activo'
);

38
sql/agent_runs_rls.sql Normal file
View File

@@ -0,0 +1,38 @@
-- Idempotent: enable RLS + tenant policies on agent_runs
-- Safe to re-run.
alter table public.agent_runs enable row level security;
drop policy if exists agent_runs_tenant_select on public.agent_runs;
drop policy if exists agent_runs_tenant_insert on public.agent_runs;
drop policy if exists agent_runs_tenant_update on public.agent_runs;
create policy agent_runs_tenant_select on public.agent_runs
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy agent_runs_tenant_insert on public.agent_runs
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy agent_runs_tenant_update on public.agent_runs
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Verify
select c.relname, c.relrowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname = 'agent_runs';

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

64
sql/asset_files.sql Normal file
View File

@@ -0,0 +1,64 @@
-- File assets library (validated uploads per tenant)
-- Idempotent.
create table if not exists public.asset_files (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
title text not null,
folder text not null default '',
file_name text not null,
mime_type text,
bytes bigint,
storage_path text not null,
sha256 text,
status text not null default 'approved'
check (status in ('approved', 'archived')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists asset_files_tenant_folder_idx
on public.asset_files (tenant_id, folder, created_at desc);
alter table public.asset_files enable row level security;
drop policy if exists asset_files_tenant_select on public.asset_files;
drop policy if exists asset_files_tenant_insert on public.asset_files;
drop policy if exists asset_files_tenant_update on public.asset_files;
drop policy if exists asset_files_tenant_delete on public.asset_files;
create policy asset_files_tenant_select on public.asset_files
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_insert on public.asset_files
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_update on public.asset_files
for update using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy asset_files_tenant_delete on public.asset_files
for delete using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- Private bucket (created via API if missing). Policies for storage.objects:
-- path layout: {tenant_id}/{optional/folder/}{uuid}_{filename}

31
sql/cms_integrations.sql Normal file
View File

@@ -0,0 +1,31 @@
-- CMS / ecommerce connection slots per tenant
-- Idempotent.
alter table public.integrations drop constraint if exists integrations_provider_check;
alter table public.integrations
add constraint integrations_provider_check
check (provider in (
'meta_ads',
'google_ads',
'ga4',
'search_console',
'tag_manager',
'clientify',
'klaviyo',
'mailchimp',
'google_drive',
'wordpress',
'prestashop',
'shopify'
));
insert into public.integrations (tenant_id, provider, status, label)
select t.id, p.provider, 'disconnected', p.label
from public.tenants t
cross join (
values
('wordpress', 'WordPress'),
('prestashop', 'PrestaShop'),
('shopify', 'Shopify')
) as p(provider, label)
on conflict (tenant_id, provider) do nothing;

180
sql/content_collab.sql Normal file
View File

@@ -0,0 +1,180 @@
-- Dual approval (agency + client) + threaded comments for calendar posts & assets
-- Idempotent. Private portal only (no public publish).
-- ---------------------------------------------------------------------------
-- Calendar posts (normalized; private per tenant)
-- ---------------------------------------------------------------------------
create table if not exists public.calendar_posts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
external_key text,
month text,
date date not null,
time_slot text,
platform text not null
check (platform in (
'instagram', 'facebook', 'linkedin', 'tiktok', 'youtube', 'x', 'other'
)),
format text,
pillar text,
title text not null,
hook text,
body text,
cta text,
hashtags text,
objective_metric text,
status text not null default 'draft'
check (status in ('draft', 'in_review', 'scheduled', 'published', 'archived')),
agency_approved_at timestamptz,
agency_approved_by uuid references auth.users(id),
client_approved_at timestamptz,
client_approved_by uuid references auth.users(id),
meta jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, external_key)
);
create index if not exists calendar_posts_tenant_month_idx
on public.calendar_posts (tenant_id, month, date);
create index if not exists calendar_posts_tenant_platform_idx
on public.calendar_posts (tenant_id, platform, date);
alter table public.calendar_posts enable row level security;
drop policy if exists calendar_posts_tenant_select on public.calendar_posts;
drop policy if exists calendar_posts_tenant_write on public.calendar_posts;
create policy calendar_posts_tenant_select on public.calendar_posts
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy calendar_posts_tenant_write on public.calendar_posts
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Shared comments (calendar_post | asset)
-- ---------------------------------------------------------------------------
create table if not exists public.content_comments (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
resource_id text not null,
body text not null,
author_id uuid references auth.users(id),
author_email text,
author_party text not null check (author_party in ('agency', 'client')),
created_at timestamptz not null default now()
);
create index if not exists content_comments_resource_idx
on public.content_comments (tenant_id, resource_type, resource_id, created_at);
alter table public.content_comments enable row level security;
drop policy if exists content_comments_tenant_select on public.content_comments;
drop policy if exists content_comments_tenant_insert on public.content_comments;
create policy content_comments_tenant_select on public.content_comments
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy content_comments_tenant_insert on public.content_comments
for insert with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Dual approval events (calendar_post | asset)
-- ---------------------------------------------------------------------------
create table if not exists public.content_approvals (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
resource_type text not null check (resource_type in ('calendar_post', 'asset')),
resource_id text not null,
party text not null check (party in ('agency', 'client')),
approved boolean not null default true,
note text,
actor_id uuid references auth.users(id),
actor_email text,
created_at timestamptz not null default now(),
unique (tenant_id, resource_type, resource_id, party)
);
alter table public.content_approvals enable row level security;
drop policy if exists content_approvals_tenant_select on public.content_approvals;
drop policy if exists content_approvals_tenant_write on public.content_approvals;
create policy content_approvals_tenant_select on public.content_approvals
for select using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
create policy content_approvals_tenant_write on public.content_approvals
for all using (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
)
with check (
tenant_id::text = coalesce(
auth.jwt() -> 'app_metadata' ->> 'tenant_id',
auth.jwt() -> 'user_metadata' ->> 'tenant_id'
)
);
-- ---------------------------------------------------------------------------
-- Assets: dual validation columns
-- ---------------------------------------------------------------------------
alter table public.asset_files
add column if not exists review_status text not null default 'in_review';
alter table public.asset_files
drop constraint if exists asset_files_review_status_check;
alter table public.asset_files
add constraint asset_files_review_status_check
check (review_status in ('draft', 'in_review', 'validated', 'archived'));
alter table public.asset_files
add column if not exists agency_approved_at timestamptz;
alter table public.asset_files
add column if not exists agency_approved_by uuid references auth.users(id);
alter table public.asset_files
add column if not exists client_approved_at timestamptz;
alter table public.asset_files
add column if not exists client_approved_by uuid references auth.users(id);
comment on table public.calendar_posts is
'Private RRSS calendar per tenant. Dual agency/client approval. No public publish.';
comment on table public.content_comments is
'Comments on calendar posts or assets (agency + client).';
comment on table public.content_approvals is
'Dual-check approvals: agency and client must both approve for full OK.';

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