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>
This commit is contained in:
95
scripts/dokploy_apply_pipeline_schedule.py
Normal file
95
scripts/dokploy_apply_pipeline_schedule.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/creative_pipeline_schedule.sql via Dokploy supabase-db terminal."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
SQL = ROOT / "sql" / "creative_pipeline_schedule.sql"
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
|
||||||
|
|
||||||
|
def env(key: str) -> str:
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
p = BPS / ".env"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
for line in p.read_text().splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = env("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("missing DOKPLOY_PASSWORD", flush=True)
|
||||||
|
return 1
|
||||||
|
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||||
|
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||||
|
"supabase-db"
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.get_by_text("Terminal", exact=True).click()
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
if page.locator("button", has_text="Bash").count():
|
||||||
|
page.locator("button", has_text="Bash").first.click()
|
||||||
|
page.wait_for_timeout(2800)
|
||||||
|
if page.locator("textarea.xterm-helper-textarea").count():
|
||||||
|
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||||
|
|
||||||
|
def run(cmd: str, wait: int = 800) -> None:
|
||||||
|
page.keyboard.type(cmd, delay=1)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(wait)
|
||||||
|
|
||||||
|
run("rm -f /tmp/cps.b64 /tmp/cps.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/cps.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/cps.b64 > /tmp/cps.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/cps.sql; echo EXIT:$?",
|
||||||
|
14000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
print(text[-800:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
52
sql/creative_pipeline_schedule.sql
Normal file
52
sql/creative_pipeline_schedule.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- Agency-only scheduling for editorial creativities.
|
||||||
|
-- aprobado + scheduled_at → cron publishes IG/FB; status becomes publicado.
|
||||||
|
-- Idempotent.
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists scheduled_at timestamptz;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists channels text[] not null default array['instagram','facebook']::text[];
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_status text not null default 'idle';
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint where conname = 'creative_pipeline_publish_status_chk'
|
||||||
|
) then
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add constraint creative_pipeline_publish_status_chk
|
||||||
|
check (publish_status in ('idle', 'queued', 'publishing', 'published', 'failed'));
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_attempts int not null default 0;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists published_at timestamptz;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists meta_ig_media_id text;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists meta_fb_post_id text;
|
||||||
|
|
||||||
|
alter table public.creative_pipeline
|
||||||
|
add column if not exists publish_error text;
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_due_publish_idx
|
||||||
|
on public.creative_pipeline (tenant_id, scheduled_at)
|
||||||
|
where status = 'aprobado' and publish_status in ('idle', 'queued', 'failed');
|
||||||
|
|
||||||
|
create index if not exists creative_pipeline_scheduled_at_idx
|
||||||
|
on public.creative_pipeline (tenant_id, scheduled_at)
|
||||||
|
where scheduled_at is not null;
|
||||||
|
|
||||||
|
comment on column public.creative_pipeline.scheduled_at is
|
||||||
|
'Agency-only: when to publish to Meta (IG/FB). Null = not scheduled.';
|
||||||
|
comment on column public.creative_pipeline.channels is
|
||||||
|
'Agency-only: subset of {instagram,facebook} to publish at scheduled_at.';
|
||||||
|
comment on column public.creative_pipeline.publish_status is
|
||||||
|
'idle|queued|publishing|published|failed — worker state separate from editorial status.';
|
||||||
@@ -7,9 +7,14 @@ import {
|
|||||||
userRole,
|
userRole,
|
||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import {
|
||||||
|
normalizeChannels,
|
||||||
|
parseScheduledAt,
|
||||||
|
PUBLISH_STATUSES,
|
||||||
|
} from '@/lib/pipeline-schedule';
|
||||||
|
|
||||||
const SELECT =
|
const SELECT =
|
||||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at';
|
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error';
|
||||||
|
|
||||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||||
|
|
||||||
@@ -80,7 +85,10 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ items: await r.json(), isAgency });
|
return NextResponse.json({
|
||||||
|
items: redactScheduleForClient(await r.json(), isAgency),
|
||||||
|
isAgency,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
@@ -95,7 +103,29 @@ export async function GET(req: NextRequest) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ items: data ?? [], isAgency });
|
return NextResponse.json({
|
||||||
|
items: redactScheduleForClient(data ?? [], isAgency),
|
||||||
|
isAgency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactScheduleForClient<T extends Record<string, unknown>>(
|
||||||
|
rows: T[],
|
||||||
|
isAgency: boolean
|
||||||
|
): T[] {
|
||||||
|
if (isAgency) return rows;
|
||||||
|
return rows.map((row) => {
|
||||||
|
const {
|
||||||
|
publish_error: _e,
|
||||||
|
publish_attempts: _a,
|
||||||
|
meta_ig_media_id: _i,
|
||||||
|
meta_fb_post_id: _f,
|
||||||
|
publish_status: _p,
|
||||||
|
...rest
|
||||||
|
} = row as T & Record<string, unknown>;
|
||||||
|
// Clients may see scheduled day on calendar later; hide worker internals.
|
||||||
|
return rest as T;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -189,7 +219,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
const curRes = await fetch(
|
const curRes = await fetch(
|
||||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||||
id
|
id
|
||||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`,
|
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status,scheduled_at`,
|
||||||
{ headers: headers || undefined, cache: 'no-store' }
|
{ headers: headers || undefined, cache: 'no-store' }
|
||||||
);
|
);
|
||||||
const curRows = curRes.ok ? await curRes.json() : [];
|
const curRows = curRes.ok ? await curRes.json() : [];
|
||||||
@@ -219,6 +249,15 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (next === 'aprobado') {
|
if (next === 'aprobado') {
|
||||||
clean.approved_by = user.id;
|
clean.approved_by = user.id;
|
||||||
clean.approved_at = new Date().toISOString();
|
clean.approved_at = new Date().toISOString();
|
||||||
|
// If already scheduled by agency, queue for the worker.
|
||||||
|
if (agency && curRows?.[0]?.scheduled_at) {
|
||||||
|
clean.publish_status = 'queued';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next === 'rechazado' || next === 'propuesta' || next === 'en_diseno') {
|
||||||
|
if (agency) {
|
||||||
|
clean.publish_status = 'idle';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +274,45 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
if ('cover_path' in body) clean.cover_path = str(body.cover_path, 500);
|
||||||
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
if ('cover_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
|
||||||
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
|
if ('slide_paths' in body) clean.slide_paths = slidePaths(body.slide_paths);
|
||||||
|
|
||||||
|
// Scheduling — agency only. Client never receives these keys.
|
||||||
|
if ('scheduled_at' in body) {
|
||||||
|
const parsed = parseScheduledAt(body.scheduled_at);
|
||||||
|
if (parsed === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'scheduled_at inválido' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
clean.scheduled_at = parsed;
|
||||||
|
if (parsed) {
|
||||||
|
// Re-arm worker if agency reschedules after a failure.
|
||||||
|
if (
|
||||||
|
current === 'aprobado' ||
|
||||||
|
clean.status === 'aprobado' ||
|
||||||
|
!clean.status
|
||||||
|
) {
|
||||||
|
clean.publish_status = 'queued';
|
||||||
|
clean.publish_error = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clean.publish_status = 'idle';
|
||||||
|
clean.publish_error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('channels' in body) {
|
||||||
|
clean.channels = normalizeChannels(body.channels);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof body.publish_status === 'string' &&
|
||||||
|
(PUBLISH_STATUSES as readonly string[]).includes(body.publish_status)
|
||||||
|
) {
|
||||||
|
// Manual reset only — never allow client path here.
|
||||||
|
clean.publish_status = body.publish_status;
|
||||||
|
if (body.publish_status === 'idle' || body.publish_status === 'queued') {
|
||||||
|
clean.publish_error = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!headers) {
|
if (!headers) {
|
||||||
|
|||||||
143
src/app/api/cron/pipeline-publish/route.ts
Normal file
143
src/app/api/cron/pipeline-publish/route.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Cron: pick creative_pipeline rows that are aprobado + scheduled_at due,
|
||||||
|
* and (for now) mark them queued / report due set.
|
||||||
|
* Meta IG+FB publish will plug into processDueItem().
|
||||||
|
*
|
||||||
|
* Auth: Authorization: Bearer $CRON_SECRET
|
||||||
|
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
|
* "https://app.4ventures.es/api/cron/pipeline-publish"
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
const SELECT =
|
||||||
|
'id,tenant_id,title,format,status,scheduled_at,channels,publish_status,publish_attempts,cover_path,caption';
|
||||||
|
|
||||||
|
function authorize(req: NextRequest): boolean {
|
||||||
|
const secret = process.env.CRON_SECRET?.trim();
|
||||||
|
if (!secret) return false;
|
||||||
|
const auth = req.headers.get('authorization') || '';
|
||||||
|
if (auth === `Bearer ${secret}`) return true;
|
||||||
|
const q = req.nextUrl.searchParams.get('secret');
|
||||||
|
return q === secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!authorize(req)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) {
|
||||||
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const limit = Math.min(
|
||||||
|
50,
|
||||||
|
Math.max(1, Number(req.nextUrl.searchParams.get('limit') || 20))
|
||||||
|
);
|
||||||
|
const dryRun = req.nextUrl.searchParams.get('dry') === '1';
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${base}/rest/v1/creative_pipeline?status=eq.aprobado` +
|
||||||
|
`&scheduled_at=lte.${encodeURIComponent(now)}` +
|
||||||
|
`&publish_status=in.(idle,queued,failed)` +
|
||||||
|
`&select=${SELECT}&order=scheduled_at.asc&limit=${limit}`;
|
||||||
|
|
||||||
|
const list = await fetch(url, { headers, cache: 'no-store' });
|
||||||
|
if (!list.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (await list.text()).slice(0, 400) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const due = (await list.json()) as {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
title: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
channels: string[];
|
||||||
|
publish_attempts: number;
|
||||||
|
publish_status: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
dry: true,
|
||||||
|
due: due.length,
|
||||||
|
items: due.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
scheduled_at: d.scheduled_at,
|
||||||
|
channels: d.channels,
|
||||||
|
attempts: d.publish_attempts,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
ok: boolean;
|
||||||
|
detail: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const item of due) {
|
||||||
|
if (item.publish_status === 'queued') {
|
||||||
|
results.push({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
ok: true,
|
||||||
|
detail: 'already queued — waiting Meta publisher',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const attempts = item.publish_attempts || 0;
|
||||||
|
const lock = 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: 'queued',
|
||||||
|
publish_attempts: attempts,
|
||||||
|
publish_error:
|
||||||
|
'En cola. Publicación Meta (IG/FB) se conectará en el siguiente paso.',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!lock.ok) {
|
||||||
|
results.push({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
ok: false,
|
||||||
|
detail: 'queue mark failed',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
ok: true,
|
||||||
|
detail: 'queued for Meta publish',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
processed: results.length,
|
||||||
|
results,
|
||||||
|
meta: 'queue-only — Meta IG/FB publisher not wired yet',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -107,14 +107,14 @@ export default async function CalendarioPage({
|
|||||||
let pipe: PipelineCalendarRow[] = [];
|
let pipe: PipelineCalendarRow[] = [];
|
||||||
if (agency && ctx.viaView) {
|
if (agency && ctx.viaView) {
|
||||||
const r = await restGet<PipelineCalendarRow[]>(
|
const r = await restGet<PipelineCalendarRow[]>(
|
||||||
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at&order=created_at.asc&limit=2000`
|
`/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status&order=created_at.asc&limit=2000`
|
||||||
);
|
);
|
||||||
pipe = r.data || [];
|
pipe = r.data || [];
|
||||||
} else {
|
} else {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('creative_pipeline')
|
.from('creative_pipeline')
|
||||||
.select(
|
.select(
|
||||||
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at'
|
'id,external_ref,title,format,month,status,cover_path,slide_paths,caption,notes,created_at,scheduled_at,channels,publish_status'
|
||||||
)
|
)
|
||||||
.order('created_at', { ascending: true })
|
.order('created_at', { ascending: true })
|
||||||
.limit(2000);
|
.limit(2000);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Pipeline de creatividades — cola de aprobación con preview de placement
|
* Pipeline de creatividades — producción editorial (agencia programa IG/FB).
|
||||||
* (Meta/Google Ads + RRSS), estilo Ad Tracker.
|
* El cliente solo ve y aprueba/rechaza en revisión interna.
|
||||||
*/
|
*/
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import CreativesPipeline from '@/components/CreativesPipeline';
|
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
|
||||||
|
|
||||||
export default function CreativesPage() {
|
export default function CreativesPage() {
|
||||||
return <CreativesPipeline />;
|
return <CreativePipelineBoard />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface } from '@/components/ui';
|
import { PageHeader, Surface } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
scheduleLabel,
|
||||||
|
toDatetimeLocalValue,
|
||||||
|
type PublishChannel,
|
||||||
|
} from '@/lib/pipeline-schedule';
|
||||||
|
|
||||||
type PipelineItem = {
|
type PipelineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +37,11 @@ type PipelineItem = {
|
|||||||
approved_at: string | null;
|
approved_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
scheduled_at?: string | null;
|
||||||
|
channels?: string[] | null;
|
||||||
|
publish_status?: string | null;
|
||||||
|
publish_error?: string | null;
|
||||||
|
publish_attempts?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -196,7 +206,37 @@ export default function CreativePipelineBoard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (previewItem && previewItem.id === id) {
|
if (previewItem && previewItem.id === id) {
|
||||||
setPreviewItem({ ...previewItem, status });
|
setPreviewItem({ ...previewItem, ...(data.item || {}), status });
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSchedule(
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
scheduled_at: string | null;
|
||||||
|
channels: PublishChannel[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (!isAgency) return;
|
||||||
|
setBusyId(id);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/creative-pipeline', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, ...payload }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'No se pudo guardar la programación');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previewItem && previewItem.id === id && data.item) {
|
||||||
|
setPreviewItem({ ...previewItem, ...data.item });
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -246,7 +286,11 @@ export default function CreativePipelineBoard() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Producción editorial"
|
eyebrow="Producción editorial"
|
||||||
title="Panel de Creatividades"
|
title="Panel de Creatividades"
|
||||||
description="Planificación, diseño y archivo histórico sincronizado con Instagram."
|
description={
|
||||||
|
isAgency
|
||||||
|
? 'Producción editorial. Programa IG/FB solo desde aquí (vista agencia).'
|
||||||
|
: 'Revisa y aprueba las creatividades propuestas por 4Ventures.'
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
isAgency ? (
|
isAgency ? (
|
||||||
<button
|
<button
|
||||||
@@ -436,6 +480,7 @@ export default function CreativePipelineBoard() {
|
|||||||
busy={busyId === previewItem.id}
|
busy={busyId === previewItem.id}
|
||||||
onClose={() => setPreviewItem(null)}
|
onClose={() => setPreviewItem(null)}
|
||||||
onStatus={(s) => setStatus(previewItem.id, s)}
|
onStatus={(s) => setStatus(previewItem.id, s)}
|
||||||
|
onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -603,6 +648,28 @@ function PieceCard({
|
|||||||
{STATUS_LABEL[item.status] || item.status}
|
{STATUS_LABEL[item.status] || item.status}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{scheduleLabel(item.scheduled_at) ? (
|
||||||
|
<span className="inline-flex rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-semibold text-sky-800">
|
||||||
|
{scheduleLabel(item.scheduled_at)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.publish_status &&
|
||||||
|
item.publish_status !== 'idle' &&
|
||||||
|
isAgency ? (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold ${
|
||||||
|
item.publish_status === 'published'
|
||||||
|
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||||
|
: item.publish_status === 'failed'
|
||||||
|
? 'border-red-200 bg-red-50 text-red-800'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.publish_status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{igUrl && (
|
{igUrl && (
|
||||||
<a
|
<a
|
||||||
href={igUrl}
|
href={igUrl}
|
||||||
@@ -669,16 +736,37 @@ function DetailModal({
|
|||||||
busy,
|
busy,
|
||||||
onClose,
|
onClose,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onSaveSchedule,
|
||||||
}: {
|
}: {
|
||||||
item: PipelineItem;
|
item: PipelineItem;
|
||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onStatus: (status: string) => void;
|
onStatus: (status: string) => void;
|
||||||
|
onSaveSchedule: (payload: {
|
||||||
|
scheduled_at: string | null;
|
||||||
|
channels: PublishChannel[];
|
||||||
|
}) => void;
|
||||||
}) {
|
}) {
|
||||||
const slides = item.slide_paths || [];
|
const slides = item.slide_paths || [];
|
||||||
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
const allImages = [item.cover_path, ...slides].filter(Boolean) as string[];
|
||||||
const [activeIdx, setActiveIdx] = useState(0);
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
const [schedLocal, setSchedLocal] = useState(
|
||||||
|
toDatetimeLocalValue(item.scheduled_at)
|
||||||
|
);
|
||||||
|
const [chIg, setChIg] = useState(
|
||||||
|
(item.channels || ['instagram', 'facebook']).includes('instagram')
|
||||||
|
);
|
||||||
|
const [chFb, setChFb] = useState(
|
||||||
|
(item.channels || ['instagram', 'facebook']).includes('facebook')
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
const activePath = allImages[activeIdx] || item.cover_path;
|
const activePath = allImages[activeIdx] || item.cover_path;
|
||||||
const currentUrl = mediaUrl(activePath);
|
const currentUrl = mediaUrl(activePath);
|
||||||
@@ -690,6 +778,16 @@ function DetailModal({
|
|||||||
|
|
||||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||||
|
|
||||||
|
function submitSchedule() {
|
||||||
|
const channels: PublishChannel[] = [];
|
||||||
|
if (chIg) channels.push('instagram');
|
||||||
|
if (chFb) channels.push('facebook');
|
||||||
|
onSaveSchedule({
|
||||||
|
scheduled_at: schedLocal ? new Date(schedLocal).toISOString() : null,
|
||||||
|
channels: channels.length ? channels : ['instagram', 'facebook'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||||
@@ -820,6 +918,85 @@ function DetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAgency ? (
|
||||||
|
<div className="rounded-xl border border-sky-200 bg-sky-50/60 p-3 space-y-3">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-sky-900">
|
||||||
|
Programación (solo agencia)
|
||||||
|
</p>
|
||||||
|
<label className="block text-xs text-gray-700">
|
||||||
|
Fecha y hora
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={schedLocal}
|
||||||
|
onChange={(e) => setSchedLocal(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-gray-800">
|
||||||
|
<label className="inline-flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chIg}
|
||||||
|
onChange={(e) => setChIg(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Instagram
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chFb}
|
||||||
|
onChange={(e) => setChFb(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Facebook
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{item.publish_status && item.publish_status !== 'idle' ? (
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
Worker: <strong>{item.publish_status}</strong>
|
||||||
|
{item.publish_attempts
|
||||||
|
? ` · intentos ${item.publish_attempts}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{item.publish_error ? (
|
||||||
|
<p className="text-[11px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2 py-1.5">
|
||||||
|
{item.publish_error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={submitSchedule}
|
||||||
|
className="flex-1 rounded-lg bg-sky-700 px-3 py-2 text-xs font-semibold text-white hover:bg-sky-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Guardar programación
|
||||||
|
</button>
|
||||||
|
{schedLocal ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setSchedLocal('');
|
||||||
|
onSaveSchedule({
|
||||||
|
scheduled_at: null,
|
||||||
|
channels: ['instagram', 'facebook'],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700"
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-sky-900/80 leading-relaxed">
|
||||||
|
Requiere estado <strong>aprobado</strong> para entrar en cola.
|
||||||
|
Publica IG+FB al unísono vía cron (Meta se conecta en el
|
||||||
|
siguiente paso).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type PipelineCalendarRow = {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
publish_date?: string | null;
|
publish_date?: string | null;
|
||||||
|
scheduled_at?: string | null;
|
||||||
|
channels?: string[] | null;
|
||||||
|
publish_status?: string | null;
|
||||||
external_ref?: string | null;
|
external_ref?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,8 +35,11 @@ export function slidesOf(v: unknown): string[] {
|
|||||||
return v.filter((x): x is string => typeof x === 'string');
|
return v.filter((x): x is string => typeof x === 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Day a published pipeline piece belongs on the calendar. */
|
/** Day a pipeline piece belongs on the calendar. Prefer agency schedule. */
|
||||||
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
||||||
|
if (it.scheduled_at && /^\d{4}-\d{2}-\d{2}/.test(it.scheduled_at)) {
|
||||||
|
return it.scheduled_at.slice(0, 10);
|
||||||
|
}
|
||||||
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
|
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
|
||||||
return it.publish_date.slice(0, 10);
|
return it.publish_date.slice(0, 10);
|
||||||
}
|
}
|
||||||
@@ -43,6 +49,10 @@ export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
|||||||
const d = (it.created_at || '').slice(0, 10);
|
const d = (it.created_at || '').slice(0, 10);
|
||||||
return d || null;
|
return d || null;
|
||||||
}
|
}
|
||||||
|
// Approved + scheduled pieces also show on the calendar day (live plan).
|
||||||
|
if (st === 'aprobado' && it.scheduled_at) {
|
||||||
|
return it.scheduled_at.slice(0, 10);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +131,11 @@ export function mergePipelineIntoPlans(
|
|||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const day = pipelinePublishDay(it);
|
const day = pipelinePublishDay(it);
|
||||||
if (!day) continue;
|
if (!day) continue;
|
||||||
|
// Show published + approved-with-schedule on the live calendar.
|
||||||
|
const st = (it.status || '').toLowerCase();
|
||||||
|
if (st !== 'publicado' && !(st === 'aprobado' && it.scheduled_at)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const ig = instagramUrlFromNotes(it.notes);
|
const ig = instagramUrlFromNotes(it.notes);
|
||||||
const igKey = normPermalink(ig);
|
const igKey = normPermalink(ig);
|
||||||
|
|
||||||
|
|||||||
56
src/lib/pipeline-schedule.ts
Normal file
56
src/lib/pipeline-schedule.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/** Agency scheduling helpers for creative_pipeline. */
|
||||||
|
|
||||||
|
export const PUBLISH_CHANNELS = ['instagram', 'facebook'] as const;
|
||||||
|
export type PublishChannel = (typeof PUBLISH_CHANNELS)[number];
|
||||||
|
|
||||||
|
export const PUBLISH_STATUSES = [
|
||||||
|
'idle',
|
||||||
|
'queued',
|
||||||
|
'publishing',
|
||||||
|
'published',
|
||||||
|
'failed',
|
||||||
|
] as const;
|
||||||
|
export type PublishStatus = (typeof PUBLISH_STATUSES)[number];
|
||||||
|
|
||||||
|
export function normalizeChannels(raw: unknown): PublishChannel[] {
|
||||||
|
const list = Array.isArray(raw)
|
||||||
|
? raw.map((x) => String(x).toLowerCase().trim())
|
||||||
|
: typeof raw === 'string'
|
||||||
|
? raw.split(',').map((x) => x.toLowerCase().trim())
|
||||||
|
: [];
|
||||||
|
const out = PUBLISH_CHANNELS.filter((c) => list.includes(c));
|
||||||
|
return out.length ? [...out] : ['instagram', 'facebook'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse datetime-local or ISO into timestamptz ISO, or null to clear. */
|
||||||
|
export function parseScheduledAt(raw: unknown): string | null | undefined {
|
||||||
|
if (raw === null) return null;
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (typeof raw !== 'string') return undefined;
|
||||||
|
const t = raw.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
const d = new Date(t);
|
||||||
|
if (Number.isNaN(d.getTime())) return undefined;
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDatetimeLocalValue(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleLabel(iso?: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleString('es-ES', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user