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>
This commit is contained in:
4Ventures
2026-10-06 18:01:11 +02:00
parent 12ecde1b3b
commit c2bbbfcd06
6 changed files with 884 additions and 56 deletions

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,12 @@
-- Link pipeline creatives to RRSS calendar pieces (documents content.pieces[].id).
-- Idempotent.
alter table public.creative_pipeline
add column if not exists calendar_piece_id text;
create index if not exists creative_pipeline_calendar_piece_idx
on public.creative_pipeline (tenant_id, calendar_piece_id)
where calendar_piece_id is not null;
comment on column public.creative_pipeline.calendar_piece_id is
'Stable id of the RRSS calendar piece (documents type=calendar → pieces[].id). Agency links pipeline ↔ editorial plan.';

View File

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

View File

@@ -12,9 +12,17 @@ import {
parseScheduledAt, parseScheduledAt,
PUBLISH_STATUSES, PUBLISH_STATUSES,
} from '@/lib/pipeline-schedule'; } from '@/lib/pipeline-schedule';
import {
asPlanObject,
buildCaption,
mapFormatToPipeline,
piecesFromDocs,
scheduledAtFromPiece,
type CalendarDoc,
} from '@/lib/calendar-documents';
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,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error'; 'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']); const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
@@ -153,29 +161,81 @@ export async function POST(req: NextRequest) {
); );
} }
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const title = str(body.title, 300);
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const format = str(body.format, 20);
const row = {
tenant_id: ctx.tenantId,
title,
external_ref: str(body.external_ref, 80),
format: format && FORMATS.has(format) ? format : 'post',
month: str(body.month, 7),
caption: str(body.caption, 4000),
notes: str(body.notes, 2000),
status: 'propuesta',
created_by: user.id,
};
const headers = serviceHeaders(); const headers = serviceHeaders();
if (!headers) { if (!headers) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 }); return NextResponse.json({ error: 'Service role required' }, { status: 503 });
} }
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const calendarPieceId = str(body.calendar_piece_id, 120);
let title = str(body.title, 300);
let format = str(body.format, 20);
let month = str(body.month, 7);
let caption = str(body.caption, 4000);
let notes = str(body.notes, 2000);
let scheduledAt = parseScheduledAt(body.scheduled_at);
let channels = 'channels' in body ? normalizeChannels(body.channels) : null;
const resolvedCalendarId = calendarPieceId;
// Inherit from RRSS calendar piece when linked.
if (calendarPieceId) {
const docsRes = await fetch(
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : [];
const cal = piecesFromDocs(docs).find((p) => p.id === calendarPieceId);
if (cal) {
title = title || cal.title;
format = format || mapFormatToPipeline(cal.format, cal.kind);
month = month || cal.month;
caption = caption || buildCaption(cal) || null;
if (!notes) {
const bits = [cal.pillar ? `Pilar: ${cal.pillar}` : '', cal.platform]
.filter(Boolean)
.join(' · ');
notes = bits || null;
}
if (scheduledAt === undefined || scheduledAt === null) {
scheduledAt = scheduledAtFromPiece(cal);
}
if (!channels) {
const plat = (cal.platform || '').toLowerCase();
channels =
plat.includes('linkedin')
? ['instagram']
: plat.includes('facebook')
? ['facebook', 'instagram']
: ['instagram', 'facebook'];
}
}
}
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
const row: Record<string, unknown> = {
tenant_id: ctx.tenantId,
title,
external_ref: str(body.external_ref, 80),
format: format && FORMATS.has(format) ? format : 'post',
month: month || null,
caption: caption || null,
notes: notes || null,
status: 'propuesta',
created_by: user.id,
calendar_piece_id: resolvedCalendarId || null,
};
if (scheduledAt) {
row.scheduled_at = scheduledAt;
row.publish_status = 'idle';
}
if (channels) row.channels = channels;
const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, { const r = await fetch(`${supabaseUrl()}/rest/v1/creative_pipeline`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -189,7 +249,39 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 }); return NextResponse.json({ error: await r.text() }, { status: 500 });
} }
const data = await r.json(); const data = await r.json();
return NextResponse.json({ item: Array.isArray(data) ? data[0] : data }, { status: 201 }); const item = Array.isArray(data) ? data[0] : data;
// Bidirectional: stamp calendar piece with pipeline_id for later merges.
if (resolvedCalendarId && item?.id && headers) {
const docsRes = await fetch(
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
const docs = docsRes.ok ? ((await docsRes.json()) as CalendarDoc[]) : [];
for (const doc of docs) {
const plan = asPlanObject(doc.content);
if (!plan || !Array.isArray(plan.pieces)) continue;
const pieces = plan.pieces as { id: string; pipeline_id?: string }[];
const idx = pieces.findIndex((p) => p.id === resolvedCalendarId);
if (idx < 0) continue;
pieces[idx] = { ...pieces[idx], pipeline_id: item.id };
plan.pieces = pieces;
await fetch(`${supabaseUrl()}/rest/v1/documents?id=eq.${doc.id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ content: plan }),
});
break;
}
}
return NextResponse.json({ item }, { status: 201 });
} }
export async function PATCH(req: NextRequest) { export async function PATCH(req: NextRequest) {
@@ -274,6 +366,9 @@ 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);
if ('calendar_piece_id' in body) {
clean.calendar_piece_id = str(body.calendar_piece_id, 120);
}
// Scheduling — agency only. Client never receives these keys. // Scheduling — agency only. Client never receives these keys.
if ('scheduled_at' in body) { if ('scheduled_at' in body) {

View File

@@ -21,6 +21,30 @@ import {
toDatetimeLocalValue, toDatetimeLocalValue,
type PublishChannel, type PublishChannel,
} from '@/lib/pipeline-schedule'; } from '@/lib/pipeline-schedule';
import {
buildCaption,
mapFormatToPipeline,
mapPipelineFormatToCalendar,
scheduledAtFromPiece,
} from '@/lib/calendar-documents';
type CalendarOption = {
id: string;
title: string;
date: string;
time?: string;
month: string;
format?: string;
kind?: string;
pillar?: string;
hook?: string;
body?: string;
cta?: string;
hashtags?: string;
platform?: string;
linked_pipeline?: boolean;
pipeline_id?: string | null;
};
type PipelineItem = { type PipelineItem = {
id: string; id: string;
@@ -42,6 +66,7 @@ type PipelineItem = {
publish_status?: string | null; publish_status?: string | null;
publish_error?: string | null; publish_error?: string | null;
publish_attempts?: number | null; publish_attempts?: number | null;
calendar_piece_id?: string | null;
}; };
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
@@ -112,9 +137,17 @@ export default function CreativePipelineBoard() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [calPieces, setCalPieces] = useState<CalendarOption[]>([]);
const [calPick, setCalPick] = useState<string>('__new__');
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post'); const [newFormat, setNewFormat] = useState<PipelineItem['format']>('post');
const [newMonth, setNewMonth] = useState(''); const [newMonth, setNewMonth] = useState('');
const [newCaption, setNewCaption] = useState('');
const [newHook, setNewHook] = useState('');
const [newPillar, setNewPillar] = useState('');
const [newDate, setNewDate] = useState('');
const [newTime, setNewTime] = useState('10:00');
const [syncCalendar, setSyncCalendar] = useState(true);
const [selectedMonth, setSelectedMonth] = useState<string>('all'); const [selectedMonth, setSelectedMonth] = useState<string>('all');
const [selectedFormat, setSelectedFormat] = useState<string>('all'); const [selectedFormat, setSelectedFormat] = useState<string>('all');
const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null); const [previewItem, setPreviewItem] = useState<PipelineItem | null>(null);
@@ -132,10 +165,65 @@ export default function CreativePipelineBoard() {
} }
} }
async function loadCalendarPieces() {
try {
const r = await fetch('/api/calendar/pieces');
const j = await r.json();
if (r.ok) setCalPieces((j.pieces || []) as CalendarOption[]);
} catch {
/* ignore */
}
}
useEffect(() => { useEffect(() => {
void load(); void load();
}, []); }, []);
useEffect(() => {
if (showForm && isAgency) void loadCalendarPieces();
}, [showForm, isAgency]);
function applyCalendarPiece(id: string) {
setCalPick(id);
if (id === '__new__') {
setNewTitle('');
setNewCaption('');
setNewHook('');
setNewPillar('');
setNewFormat('post');
setNewMonth('');
setNewDate('');
setNewTime('10:00');
return;
}
const p = calPieces.find((x) => x.id === id);
if (!p) return;
setNewTitle(p.title || '');
setNewHook(p.hook || '');
setNewCaption(buildCaption(p) || p.body || '');
setNewPillar(p.pillar || '');
setNewFormat(
mapFormatToPipeline(p.format, p.kind) as PipelineItem['format']
);
setNewMonth(p.month || p.date.slice(0, 7));
setNewDate(p.date.slice(0, 10));
setNewTime((p.time || '10:00').slice(0, 5));
}
function resetForm() {
setCalPick('__new__');
setNewTitle('');
setNewCaption('');
setNewHook('');
setNewPillar('');
setNewFormat('post');
setNewMonth('');
setNewDate('');
setNewTime('10:00');
setSyncCalendar(true);
setShowForm(false);
}
const monthCounts = useMemo(() => { const monthCounts = useMemo(() => {
const map = new Map<string, number>(); const map = new Map<string, number>();
for (const it of items) { for (const it of items) {
@@ -155,7 +243,8 @@ export default function CreativePipelineBoard() {
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
return items.filter((it) => { return items.filter((it) => {
if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth) return false; if (selectedMonth !== 'all' && (it.month || 'sin-mes') !== selectedMonth)
return false;
if (selectedFormat !== 'all' && it.format !== selectedFormat) return false; if (selectedFormat !== 'all' && it.format !== selectedFormat) return false;
return true; return true;
}); });
@@ -175,19 +264,64 @@ export default function CreativePipelineBoard() {
e.preventDefault(); e.preventDefault();
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
setErr(null); setErr(null);
let calendarPieceId: string | null =
calPick !== '__new__' ? calPick : null;
if (syncCalendar) {
const date =
newDate ||
(newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10));
const calFmt = mapPipelineFormatToCalendar(newFormat);
const calRes = await fetch('/api/calendar/pieces', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pieceId: calendarPieceId,
title: newTitle.trim(),
date,
time: newTime || '10:00',
format: calFmt.format,
kind: calFmt.kind,
hook: newHook || undefined,
body: newCaption || undefined,
pillar: newPillar || undefined,
platform: 'instagram',
}),
});
const calData = await calRes.json();
if (!calRes.ok) {
setErr(calData.error || 'No se pudo guardar en el calendario RRSS');
return;
}
calendarPieceId = calData.piece?.id || calendarPieceId;
void loadCalendarPieces();
}
const sched = newDate
? scheduledAtFromPiece({ date: newDate, time: newTime })
: null;
const res = await fetch('/api/creative-pipeline', { const res = await fetch('/api/creative-pipeline', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle, format: newFormat, month: newMonth || null }), body: JSON.stringify({
title: newTitle.trim(),
format: newFormat,
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
caption: newCaption || null,
notes: newPillar ? `Pilar: ${newPillar}` : null,
calendar_piece_id: calendarPieceId,
scheduled_at: sched,
channels: ['instagram', 'facebook'],
}),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setErr(data.error || 'Error al crear'); setErr(data.error || 'Error al crear');
return; return;
} }
setNewTitle(''); resetForm();
setNewMonth('');
setShowForm(false);
await load(); await load();
} }
@@ -387,22 +521,54 @@ export default function CreativePipelineBoard() {
{showForm && isAgency ? ( {showForm && isAgency ? (
<Surface className="mb-6"> <Surface className="mb-6">
<form onSubmit={createItem} className="flex flex-wrap items-end gap-3"> <form onSubmit={createItem} className="space-y-4">
<div> <div>
<label className="block text-xs font-medium text-gray-600">Título</label> <label className="block text-xs font-semibold text-gray-700">
Publicación del calendario RRSS
</label>
<select
value={calPick}
onChange={(e) => applyCalendarPiece(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="__new__">+ Nueva publicación (no está en el calendario)</option>
{calPieces.map((p) => (
<option key={p.id} value={p.id}>
{p.date}
{p.time ? ` ${p.time.slice(0, 5)}` : ''} · {p.title}
{p.linked_pipeline ? ' · ya en pipeline' : ''}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-gray-500">
Elige una pieza del plan editorial del cliente (Contexto) para
heredar título, copy, pilar y fecha. O crea una nueva.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-600">
Título
</label>
<input <input
value={newTitle} value={newTitle}
onChange={(e) => setNewTitle(e.target.value)} onChange={(e) => setNewTitle(e.target.value)}
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Certifica tu nivel" placeholder="Nombre de la pieza"
required
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-600">Formato</label> <label className="block text-xs font-medium text-gray-600">
Formato
</label>
<select <select
value={newFormat} value={newFormat}
onChange={(e) => setNewFormat(e.target.value as PipelineItem['format'])} onChange={(e) =>
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" setNewFormat(e.target.value as PipelineItem['format'])
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
> >
<option value="post">Post</option> <option value="post">Post</option>
<option value="story">Story</option> <option value="story">Story</option>
@@ -411,20 +577,108 @@ export default function CreativePipelineBoard() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-600">Mes</label> <label className="block text-xs font-medium text-gray-600">
Pilar / temática
</label>
<input
value={newPillar}
onChange={(e) => setNewPillar(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Educativo, reseña…"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600">
Fecha
</label>
<input
type="date"
value={newDate}
onChange={(e) => {
setNewDate(e.target.value);
if (e.target.value) setNewMonth(e.target.value.slice(0, 7));
}}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600">
Hora
</label>
<input
type="time"
value={newTime}
onChange={(e) => setNewTime(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600">
Mes (agrupación)
</label>
<input <input
type="month" type="month"
value={newMonth} value={newMonth}
onChange={(e) => setNewMonth(e.target.value)} onChange={(e) => setNewMonth(e.target.value)}
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm" className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/> />
</div> </div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-xs font-medium text-gray-600">
Hook
</label>
<input
value={newHook}
onChange={(e) => setNewHook(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Gancho / primera línea"
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-xs font-medium text-gray-600">
Copy / caption
</label>
<textarea
value={newCaption}
onChange={(e) => setNewCaption(e.target.value)}
rows={3}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Texto completo del anuncio"
/>
</div>
</div>
<label className="flex items-start gap-2 text-xs text-gray-700">
<input
type="checkbox"
className="mt-0.5"
checked={syncCalendar}
onChange={(e) => setSyncCalendar(e.target.checked)}
/>
<span>
{calPick === '__new__'
? 'Crear también en el calendario RRSS del cliente'
: 'Actualizar la pieza del calendario RRSS con estos datos (editar antigua)'}
</span>
</label>
<div className="flex flex-wrap gap-2">
<button <button
type="submit" type="submit"
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800" className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
> >
Crear {calPick === '__new__'
? 'Crear en pipeline'
: 'Añadir al pipeline (desde calendario)'}
</button> </button>
<button
type="button"
onClick={resetForm}
className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700"
>
Cancelar
</button>
</div>
</form> </form>
</Surface> </Surface>
) : null} ) : null}
@@ -654,6 +908,12 @@ function PieceCard({
</span> </span>
) : null} ) : null}
{item.calendar_piece_id && isAgency ? (
<span className="inline-flex rounded-full border border-violet-200 bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-800">
Calendario
</span>
) : null}
{item.publish_status && {item.publish_status &&
item.publish_status !== 'idle' && item.publish_status !== 'idle' &&
isAgency ? ( isAgency ? (

View File

@@ -0,0 +1,110 @@
/** Read/write RRSS calendar pieces stored in documents (type=calendar). */
import type { RrssPiece } from '@/components/RrssMonthView';
import { monthLabel, slugify } from '@/lib/calendar-import';
export type CalendarDoc = {
id: string;
title: string;
content: unknown;
};
export type CalendarPieceRow = RrssPiece & {
month: string;
document_id: string;
document_title: string;
linked_pipeline?: boolean;
};
export function asPlanObject(content: unknown): Record<string, unknown> | null {
let c = content;
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch {
return null;
}
}
if (!c || typeof c !== 'object') return null;
const o = c as Record<string, unknown>;
if (o.kind === 'rrss_month' && Array.isArray(o.pieces)) return o;
return null;
}
export function piecesFromDocs(docs: CalendarDoc[]): CalendarPieceRow[] {
const out: CalendarPieceRow[] = [];
for (const doc of docs) {
const plan = asPlanObject(doc.content);
if (!plan) continue;
const month = String(plan.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) continue;
const pieces = plan.pieces as RrssPiece[];
for (const p of pieces) {
if (!p?.id || !p.title) continue;
out.push({
...p,
month,
document_id: doc.id,
document_title: doc.title,
});
}
}
out.sort((a, b) => {
const da = `${a.date} ${a.time || ''}`.localeCompare(`${b.date} ${b.time || ''}`);
return da || a.title.localeCompare(b.title);
});
return out;
}
export function mapFormatToPipeline(format?: string, kind?: string): string {
const hay = `${format || ''} ${kind || ''}`.toLowerCase();
if (hay.includes('stor')) return 'story';
if (hay.includes('reel')) return 'reel';
if (hay.includes('carrusel') || hay.includes('carousel')) return 'carrusel';
return 'post';
}
export function mapPipelineFormatToCalendar(format: string): {
format: string;
kind: string;
} {
if (format === 'story') return { format: 'Stories', kind: 'story' };
if (format === 'reel') return { format: 'Reel', kind: 'reel' };
if (format === 'carrusel') return { format: 'Carrusel', kind: 'post' };
return { format: 'Post', kind: 'post' };
}
export function buildCaption(piece: Pick<RrssPiece, 'hook' | 'body' | 'cta' | 'hashtags'>): string {
return [piece.hook, piece.body, piece.cta, piece.hashtags]
.filter((x) => x && String(x).trim())
.join('\n\n')
.trim();
}
export function scheduledAtFromPiece(piece: Pick<RrssPiece, 'date' | 'time'>): string | null {
const day = (piece.date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null;
const time = (piece.time || '10:00').slice(0, 5);
const iso = new Date(`${day}T${time}:00`);
if (Number.isNaN(iso.getTime())) return `${day}T10:00:00.000Z`;
return iso.toISOString();
}
export function newPieceId(date: string, title: string): string {
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
}
export function emptyMonthPlan(ym: string, tenantLabel: string) {
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Piezas del plan editorial.',
chips: [],
},
pieces: [] as RrssPiece[],
};
}