From 12ecde1b3b53451ea42aa6fcbda520eff004b5df Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 17:54:00 +0200 Subject: [PATCH] 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 --- scripts/dokploy_apply_pipeline_schedule.py | 95 +++++++++++ sql/creative_pipeline_schedule.sql | 52 ++++++ src/app/api/creative-pipeline/route.ts | 86 +++++++++- src/app/api/cron/pipeline-publish/route.ts | 143 ++++++++++++++++ src/app/dashboard/calendario/page.tsx | 4 +- src/app/dashboard/creatives/page.tsx | 8 +- src/components/CreativePipelineBoard.tsx | 181 ++++++++++++++++++++- src/lib/pipeline-calendar.ts | 17 +- src/lib/pipeline-schedule.ts | 56 +++++++ 9 files changed, 629 insertions(+), 13 deletions(-) create mode 100644 scripts/dokploy_apply_pipeline_schedule.py create mode 100644 sql/creative_pipeline_schedule.sql create mode 100644 src/app/api/cron/pipeline-publish/route.ts create mode 100644 src/lib/pipeline-schedule.ts diff --git a/scripts/dokploy_apply_pipeline_schedule.py b/scripts/dokploy_apply_pipeline_schedule.py new file mode 100644 index 0000000..933954e --- /dev/null +++ b/scripts/dokploy_apply_pipeline_schedule.py @@ -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()) diff --git a/sql/creative_pipeline_schedule.sql b/sql/creative_pipeline_schedule.sql new file mode 100644 index 0000000..6365e7c --- /dev/null +++ b/sql/creative_pipeline_schedule.sql @@ -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.'; diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index 40d1767..a9073de 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -7,9 +7,14 @@ import { userRole, } from '@/lib/agency'; import { resolveActiveTenant } from '@/lib/tenant-context'; +import { + normalizeChannels, + parseScheduledAt, + PUBLISH_STATUSES, +} from '@/lib/pipeline-schedule'; 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']); @@ -80,7 +85,10 @@ export async function GET(req: NextRequest) { if (!r.ok) { return NextResponse.json({ items: [], isAgency, warning: await r.text() }); } - return NextResponse.json({ items: await r.json(), isAgency }); + return NextResponse.json({ + items: redactScheduleForClient(await r.json(), isAgency), + isAgency, + }); } let query = supabase @@ -95,7 +103,29 @@ export async function GET(req: NextRequest) { if (error) { return NextResponse.json({ items: [], isAgency, warning: error.message }); } - return NextResponse.json({ items: data ?? [], isAgency }); + return NextResponse.json({ + items: redactScheduleForClient(data ?? [], isAgency), + isAgency, + }); +} + +function redactScheduleForClient>( + 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; + // Clients may see scheduled day on calendar later; hide worker internals. + return rest as T; + }); } export async function POST(req: NextRequest) { @@ -189,7 +219,7 @@ export async function PATCH(req: NextRequest) { const curRes = await fetch( `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( id - )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status`, + )}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=status,scheduled_at`, { headers: headers || undefined, cache: 'no-store' } ); const curRows = curRes.ok ? await curRes.json() : []; @@ -219,6 +249,15 @@ export async function PATCH(req: NextRequest) { if (next === 'aprobado') { clean.approved_by = user.id; 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_mime' in body) clean.cover_mime = str(body.cover_mime, 100); 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) { diff --git a/src/app/api/cron/pipeline-publish/route.ts b/src/app/api/cron/pipeline-publish/route.ts new file mode 100644 index 0000000..8858227 --- /dev/null +++ b/src/app/api/cron/pipeline-publish/route.ts @@ -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', + }); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index c4ab981..46ea092 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -107,14 +107,14 @@ export default async function CalendarioPage({ let pipe: PipelineCalendarRow[] = []; if (agency && ctx.viaView) { const r = await restGet( - `/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 || []; } else { const { data } = await supabase .from('creative_pipeline') .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 }) .limit(2000); diff --git a/src/app/dashboard/creatives/page.tsx b/src/app/dashboard/creatives/page.tsx index b4ba898..69dd134 100644 --- a/src/app/dashboard/creatives/page.tsx +++ b/src/app/dashboard/creatives/page.tsx @@ -1,11 +1,11 @@ /** - * Pipeline de creatividades — cola de aprobación con preview de placement - * (Meta/Google Ads + RRSS), estilo Ad Tracker. + * Pipeline de creatividades — producción editorial (agencia programa IG/FB). + * El cliente solo ve y aprueba/rechaza en revisión interna. */ 'use client'; -import CreativesPipeline from '@/components/CreativesPipeline'; +import CreativePipelineBoard from '@/components/CreativePipelineBoard'; export default function CreativesPage() { - return ; + return ; } diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 72394f3..34930de 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -16,6 +16,11 @@ import { Filter, } from 'lucide-react'; import { PageHeader, Surface } from '@/components/ui'; +import { + scheduleLabel, + toDatetimeLocalValue, + type PublishChannel, +} from '@/lib/pipeline-schedule'; type PipelineItem = { id: string; @@ -32,6 +37,11 @@ type PipelineItem = { approved_at: string | null; created_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 = { @@ -196,7 +206,37 @@ export default function CreativePipelineBoard() { return; } 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(); } finally { @@ -246,7 +286,11 @@ export default function CreativePipelineBoard() { setPreviewItem(null)} onStatus={(s) => setStatus(previewItem.id, s)} + onSaveSchedule={(payload) => saveSchedule(previewItem.id, payload)} /> )} @@ -603,6 +648,28 @@ function PieceCard({ {STATUS_LABEL[item.status] || item.status} + {scheduleLabel(item.scheduled_at) ? ( + + {scheduleLabel(item.scheduled_at)} + + ) : null} + + {item.publish_status && + item.publish_status !== 'idle' && + isAgency ? ( + + {item.publish_status} + + ) : null} + {igUrl && ( void; onStatus: (status: string) => void; + onSaveSchedule: (payload: { + scheduled_at: string | null; + channels: PublishChannel[]; + }) => void; }) { const slides = item.slide_paths || []; const allImages = [item.cover_path, ...slides].filter(Boolean) as string[]; 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 currentUrl = mediaUrl(activePath); @@ -690,6 +778,16 @@ function DetailModal({ 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 (
)} + + {isAgency ? ( +
+

+ Programación (solo agencia) +

+ +
+ + +
+ {item.publish_status && item.publish_status !== 'idle' ? ( +

+ Worker: {item.publish_status} + {item.publish_attempts + ? ` · intentos ${item.publish_attempts}` + : ''} +

+ ) : null} + {item.publish_error ? ( +

+ {item.publish_error} +

+ ) : null} +
+ + {schedLocal ? ( + + ) : null} +
+

+ Requiere estado aprobado para entrar en cola. + Publica IG+FB al unísono vía cron (Meta se conecta en el + siguiente paso). +

+
+ ) : null}
diff --git a/src/lib/pipeline-calendar.ts b/src/lib/pipeline-calendar.ts index 51eb813..708714d 100644 --- a/src/lib/pipeline-calendar.ts +++ b/src/lib/pipeline-calendar.ts @@ -12,6 +12,9 @@ export type PipelineCalendarRow = { notes?: string | null; created_at?: string | null; publish_date?: string | null; + scheduled_at?: string | null; + channels?: string[] | null; + publish_status?: 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'); } -/** 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 { + 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)) { 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); 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; } @@ -121,6 +131,11 @@ export function mergePipelineIntoPlans( for (const it of items) { const day = pipelinePublishDay(it); 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 igKey = normPermalink(ig); diff --git a/src/lib/pipeline-schedule.ts b/src/lib/pipeline-schedule.ts new file mode 100644 index 0000000..a7f007f --- /dev/null +++ b/src/lib/pipeline-schedule.ts @@ -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', + }); +}