From b4d86e1e284def4e358eeb820abec4148d7521a2 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 18:33:10 +0200 Subject: [PATCH] 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 --- docs/OPERATOR.md | 7 + .../dokploy_ensure_pipeline_publish_cron.py | 207 ++++++++++++++++ src/app/api/cron/daily-sync/route.ts | 110 +++++++++ src/components/CreativePipelineBoard.tsx | 220 +++++++++++++++++- 4 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 scripts/dokploy_ensure_pipeline_publish_cron.py diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index ccd04b4..a4b9131 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -67,6 +67,13 @@ 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/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). diff --git a/scripts/dokploy_ensure_pipeline_publish_cron.py b/scripts/dokploy_ensure_pipeline_publish_cron.py new file mode 100644 index 0000000..bd4439d --- /dev/null +++ b/scripts/dokploy_ensure_pipeline_publish_cron.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Ensure Dokploy schedule for pipeline Meta publish (every 10 min). +Uses application.createSchedule / findAll if API available; falls back to UI. +""" +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}" +) +SCHEDULE_NAME = "pipeline-publish" +CRON_EXPR = "*/10 * * * *" +# Runs inside the app container where CRON_SECRET is already in env. +CMD = ( + 'curl -sS -H "Authorization: Bearer $CRON_SECRET" ' + '"http://127.0.0.1:3000/api/cron/pipeline-publish" || ' + 'curl -sS -H "Authorization: Bearer $CRON_SECRET" ' + '"https://app.4ventures.es/api/cron/pipeline-publish"' +) + + +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) + + # Probe Dokploy schedule APIs + probe = page.evaluate( + """async ({ appId, name, cron, command }) => { + const tryFetch = 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, path, text: text.slice(0, 500), json }; + }; + const listPaths = [ + '/api/trpc/schedule.all', + '/api/application.one', + ]; + const outs = {}; + // application.one to inspect shape + outs.one = await tryFetch('/api/application.one', { applicationId: appId }); + // Dokploy v0. something: schedule.create + const createBodies = [ + { applicationId: appId, name, cron, command, shellType: 'bash' }, + { applicationId: appId, scheduleName: name, cronExpression: cron, command, enabled: true }, + { applicationId: appId, name, cronExpression: cron, command }, + ]; + const createPaths = [ + '/api/schedule.create', + '/api/application.createSchedule', + '/api/trpc/schedule.create', + ]; + outs.creates = []; + for (const path of createPaths) { + for (const body of createBodies) { + const r = await tryFetch(path, body); + outs.creates.push(r); + if (r.ok) return { ok: true, via: path, r }; + } + } + // list schedules + for (const path of [ + '/api/schedule.all', + '/api/application.schedules', + '/api/schedule.byApplicationId', + ]) { + outs['list_'+path] = await tryFetch(path, { applicationId: appId }); + } + return { ok: false, outs }; + }""", + { + "appId": APP_ID, + "name": SCHEDULE_NAME, + "cron": CRON_EXPR, + "command": CMD, + }, + ) + print("API_PROBE", json.dumps(probe, indent=2)[:4000], flush=True) + + if probe.get("ok"): + print("SCHEDULE_OK_API", flush=True) + browser.close() + return 0 + + # UI fallback: Schedules tab + page.goto(APP, wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(1000) + tab = page.get_by_role("tab", name=re.compile(r"Schedule", re.I)) + if not tab.count(): + # try Advanced / Domains area + for name in ("Schedules", "Cron", "Advanced"): + t = page.get_by_text(re.compile(rf"^{name}$", re.I)) + if t.count(): + t.first.click() + page.wait_for_timeout(600) + break + else: + tab.first.click() + page.wait_for_timeout(800) + + body = page.inner_text("body") + print("UI_BODY_SNIP", body[:1500].replace("\n", " | "), flush=True) + + if SCHEDULE_NAME in body or "pipeline-publish" in body: + print("SCHEDULE_ALREADY_PRESENT", flush=True) + browser.close() + return 0 + + add = page.get_by_role("button", name=re.compile(r"Add|Create|New|Schedule", re.I)) + if add.count(): + add.first.click() + page.wait_for_timeout(700) + # fill fields heuristically + inputs = page.locator("input, textarea") + n = inputs.count() + filled = 0 + for i in range(min(n, 12)): + el = inputs.nth(i) + try: + typ = (el.get_attribute("type") or "").lower() + name = (el.get_attribute("name") or el.get_attribute("placeholder") or "").lower() + if typ == "hidden": + continue + if "name" in name or filled == 0: + el.fill(SCHEDULE_NAME) + filled += 1 + elif "cron" in name or filled == 1: + el.fill(CRON_EXPR) + filled += 1 + elif "command" in name or "cmd" in name or filled == 2: + el.fill(CMD) + filled += 1 + except Exception: + pass + save = page.get_by_role("button", name=re.compile(r"Save|Create|Confirm|Add", re.I)) + if save.count(): + save.first.click() + page.wait_for_timeout(1200) + print("SCHEDULE_UI_SAVED", flush=True) + browser.close() + return 0 + + print("SCHEDULE_NEEDS_MANUAL", flush=True) + # Persist ops note + note = ROOT / ".ops" / "pipeline-publish-cron.txt" + note.write_text( + f"Schedule needed on Dokploy app {APP_ID}:\n" + f" name: {SCHEDULE_NAME}\n" + f" cron: {CRON_EXPR}\n" + f" command:\n{CMD}\n", + encoding="utf-8", + ) + browser.close() + return 0 # non-fatal: docs + note written + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/app/api/cron/daily-sync/route.ts b/src/app/api/cron/daily-sync/route.ts index d76e53d..95b1b83 100644 --- a/src/app/api/cron/daily-sync/route.ts +++ b/src/app/api/cron/daily-sync/route.ts @@ -9,10 +9,110 @@ import { } from '@/lib/superhuman-docs'; import { syncAllClientifyTenants } from '@/lib/clientify-sync'; import { syncAllGhlTenants } from '@/lib/gohighlevel-sync'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; +import { + mergePermalinkNotes, + publishPipelineItem, + type PipelinePublishRow, +} from '@/lib/meta-publish'; export const dynamic = 'force-dynamic'; export const maxDuration = 300; +const PIPELINE_SELECT = + 'id,tenant_id,title,format,status,scheduled_at,channels,publish_status,publish_attempts,cover_path,cover_mime,slide_paths,caption,notes,meta_ig_media_id,meta_fb_post_id'; + +async function runDuePipelinePublish(limit = 5): Promise> { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) { + return { ok: false, error: 'Service role required' }; + } + const now = new Date().toISOString(); + const url = + `${base}/rest/v1/creative_pipeline?status=eq.aprobado` + + `&scheduled_at=lte.${encodeURIComponent(now)}` + + `&publish_status=in.(idle,queued,failed)` + + `&select=${PIPELINE_SELECT}&order=scheduled_at.asc&limit=${limit}`; + const list = await fetch(url, { headers, cache: 'no-store' }); + if (!list.ok) { + return { ok: false, error: (await list.text()).slice(0, 200) }; + } + const due = (await list.json()) as (PipelinePublishRow & { + title: string; + notes?: string | null; + })[]; + const results: { id: string; ok: boolean; detail: string }[] = []; + for (const item of due) { + if (!item.cover_path) { + results.push({ id: item.id, ok: false, detail: 'missing cover' }); + continue; + } + await fetch(`${base}/rest/v1/creative_pipeline?id=eq.${item.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + publish_status: 'publishing', + publish_attempts: (item.publish_attempts || 0) + 1, + publish_error: null, + updated_at: new Date().toISOString(), + }), + }); + const result = await publishPipelineItem(item); + if (!result.ok) { + await fetch(`${base}/rest/v1/creative_pipeline?id=eq.${item.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + publish_status: 'failed', + publish_error: result.error || 'Error Meta', + meta_ig_media_id: result.igMediaId || item.meta_ig_media_id || null, + meta_fb_post_id: result.fbPostId || item.meta_fb_post_id || null, + updated_at: new Date().toISOString(), + }), + }); + results.push({ + id: item.id, + ok: false, + detail: result.error || 'failed', + }); + continue; + } + await fetch(`${base}/rest/v1/creative_pipeline?id=eq.${item.id}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + publish_status: 'published', + publish_error: null, + published_at: new Date().toISOString(), + status: 'publicado', + meta_ig_media_id: result.igMediaId || null, + meta_fb_post_id: result.fbPostId || null, + notes: mergePermalinkNotes( + item.notes, + result.igPermalink, + result.fbPermalink + ), + updated_at: new Date().toISOString(), + }), + }); + results.push({ id: item.id, ok: true, detail: 'published' }); + } + return { ok: true, due: due.length, results }; +} + /** * Master daily job: organic + ads + superhuman. * Dokploy (daily ~06:00 UTC): @@ -111,6 +211,16 @@ export async function GET(req: NextRequest) { }; } + // 5) Pipeline Meta publish (safety net; prefer dedicated */10 cron) + try { + out.pipelinePublish = await runDuePipelinePublish(5); + } catch (e) { + out.pipelinePublish = { + ok: false, + error: e instanceof Error ? e.message : 'pipeline-publish failed', + }; + } + const failed = ['organic', 'ads', 'superhuman', 'crm'].some( (k) => (out[k] as { ok?: boolean } | undefined)?.ok === false ); diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 1617dc3..febcf78 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -397,6 +397,83 @@ export default function CreativePipelineBoard() { } } + /** Edit title/copy on an existing card; optionally sync RRSS calendar piece. */ + async function saveContent( + id: string, + payload: { + title: string; + caption: string; + notes?: string | null; + syncCalendar: boolean; + calendarDate?: string | null; + calendarTime?: string | null; + } + ) { + if (!isAgency) return; + setBusyId(id); + setErr(null); + try { + const target = items.find((x) => x.id === id) || previewItem; + if ( + payload.syncCalendar && + target?.calendar_piece_id && + payload.calendarDate + ) { + const calFmt = mapPipelineFormatToCalendar(target.format || 'post'); + const calRes = await fetch('/api/calendar/pieces', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pieceId: target.calendar_piece_id, + title: payload.title.trim(), + date: payload.calendarDate, + time: payload.calendarTime || '10:00', + format: calFmt.format, + kind: calFmt.kind, + body: payload.caption || undefined, + platform: 'instagram', + }), + }); + const calData = await calRes.json(); + if (!calRes.ok) { + setErr(calData.error || 'No se pudo actualizar el calendario RRSS'); + return; + } + } + + const sched = + payload.calendarDate + ? scheduledAtFromPiece({ + date: payload.calendarDate, + time: payload.calendarTime || '10:00', + }) + : undefined; + + const res = await fetch('/api/creative-pipeline', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id, + title: payload.title.trim(), + caption: payload.caption || null, + notes: payload.notes ?? undefined, + ...(sched !== undefined ? { scheduled_at: sched } : {}), + }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'No se pudo guardar'); + return; + } + if (previewItem && previewItem.id === id && data.item) { + setPreviewItem({ ...previewItem, ...data.item }); + } + await load(); + } finally { + setBusyId(null); + } + } + async function removeItem(id: string, alsoMeta = false) { setBusyId(id); setErr(null); @@ -895,6 +972,7 @@ export default function CreativePipelineBoard() { onClose={() => setPreviewItem(null)} onStatus={(s) => setStatus(previewItem.id, s)} onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)} + onSaveContent={(payload) => saveContent(previewItem.id, payload)} onPublishNow={() => publishNow(previewItem.id)} onDeleteMeta={() => deleteOnMeta(previewItem.id)} /> @@ -1198,6 +1276,7 @@ function DetailModal({ onClose, onStatus, onSaveSchedule, + onSaveContent, onPublishNow, onDeleteMeta, }: { @@ -1210,6 +1289,14 @@ function DetailModal({ scheduled_at: string | null; channels: PublishChannel[]; }) => void; + onSaveContent: (payload: { + title: string; + caption: string; + notes?: string | null; + syncCalendar: boolean; + calendarDate?: string | null; + calendarTime?: string | null; + }) => void; onPublishNow: () => void; onDeleteMeta: () => void; }) { @@ -1225,13 +1312,37 @@ function DetailModal({ const [chFb, setChFb] = useState( (item.channels || ['instagram', 'facebook']).includes('facebook') ); + const [editTitle, setEditTitle] = useState(item.title); + const [editCaption, setEditCaption] = useState(item.caption || ''); + const [syncCal, setSyncCal] = useState(Boolean(item.calendar_piece_id)); + const [editDate, setEditDate] = useState( + (item.scheduled_at || item.created_at || '').slice(0, 10) + ); + const [editTime, setEditTime] = useState(() => { + if (!item.scheduled_at) return '10:00'; + const d = new Date(item.scheduled_at); + if (Number.isNaN(d.getTime())) return '10:00'; + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + }); useEffect(() => { setSchedLocal(toDatetimeLocalValue(item.scheduled_at)); const ch = item.channels || ['instagram', 'facebook']; setChIg(ch.includes('instagram')); setChFb(ch.includes('facebook')); - }, [item.id, item.scheduled_at, item.channels]); + setEditTitle(item.title); + setEditCaption(item.caption || ''); + setSyncCal(Boolean(item.calendar_piece_id)); + setEditDate((item.scheduled_at || item.created_at || '').slice(0, 10)); + if (item.scheduled_at) { + const d = new Date(item.scheduled_at); + if (!Number.isNaN(d.getTime())) { + setEditTime( + `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + ); + } + } + }, [item.id, item.scheduled_at, item.channels, item.title, item.caption, item.calendar_piece_id, item.created_at]); const activePath = allImages[activeIdx] || item.cover_path; const currentUrl = mediaUrl(activePath); @@ -1253,6 +1364,17 @@ function DetailModal({ }); } + function submitContent() { + if (!editTitle.trim()) return; + onSaveContent({ + title: editTitle.trim(), + caption: editCaption, + syncCalendar: syncCal && Boolean(item.calendar_piece_id), + calendarDate: editDate || null, + calendarTime: editTime || '10:00', + }); + } + return (
-

{item.title}

-

- {monthLabel(item.month)} {item.external_ref ? `· Ref: ${item.external_ref}` : ''} -

+ {isAgency ? ( +
+ +

+ {monthLabel(item.month)}{' '} + {item.external_ref ? `· Ref: ${item.external_ref}` : ''} +

+
+ ) : ( + <> +

+ {item.title} +

+

+ {monthLabel(item.month)}{' '} + {item.external_ref ? `· Ref: ${item.external_ref}` : ''} +

+ + )}
{igUrl && ( @@ -1375,14 +1519,74 @@ function DetailModal({ )} - {item.caption && ( + {isAgency ? ( +
+

+ Texto / Copy +

+