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:
95
scripts/dokploy_apply_pipeline_calendar_link.py
Normal file
95
scripts/dokploy_apply_pipeline_calendar_link.py
Normal 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())
|
||||||
12
sql/creative_pipeline_calendar_link.sql
Normal file
12
sql/creative_pipeline_calendar_link.sql
Normal 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.';
|
||||||
256
src/app/api/calendar/pieces/route.ts
Normal file
256
src/app/api/calendar/pieces/route.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,44 +521,164 @@ 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">
|
||||||
<input
|
Publicación del calendario RRSS
|
||||||
value={newTitle}
|
</label>
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
|
||||||
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
|
||||||
placeholder="Certifica tu nivel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-600">Formato</label>
|
|
||||||
<select
|
<select
|
||||||
value={newFormat}
|
value={calPick}
|
||||||
onChange={(e) => setNewFormat(e.target.value as PipelineItem['format'])}
|
onChange={(e) => applyCalendarPiece(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"
|
||||||
>
|
>
|
||||||
<option value="post">Post</option>
|
<option value="__new__">+ Nueva publicación (no está en el calendario)</option>
|
||||||
<option value="story">Story</option>
|
{calPieces.map((p) => (
|
||||||
<option value="reel">Reel</option>
|
<option key={p.id} value={p.id}>
|
||||||
<option value="carrusel">Carrusel</option>
|
{p.date}
|
||||||
|
{p.time ? ` ${p.time.slice(0, 5)}` : ''} · {p.title}
|
||||||
|
{p.linked_pipeline ? ' · ya en pipeline' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</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>
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-600">Mes</label>
|
<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
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
placeholder="Nombre de la pieza"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-600">
|
||||||
|
Formato
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newFormat}
|
||||||
|
onChange={(e) =>
|
||||||
|
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="story">Story</option>
|
||||||
|
<option value="reel">Reel</option>
|
||||||
|
<option value="carrusel">Carrusel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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
|
||||||
|
type="month"
|
||||||
|
value={newMonth}
|
||||||
|
onChange={(e) => setNewMonth(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</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
|
<input
|
||||||
type="month"
|
type="checkbox"
|
||||||
value={newMonth}
|
className="mt-0.5"
|
||||||
onChange={(e) => setNewMonth(e.target.value)}
|
checked={syncCalendar}
|
||||||
className="mt-1 rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
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
|
||||||
|
type="submit"
|
||||||
|
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{calPick === '__new__'
|
||||||
|
? 'Crear en pipeline'
|
||||||
|
: 'Añadir al pipeline (desde calendario)'}
|
||||||
|
</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>
|
</div>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Crear
|
|
||||||
</button>
|
|
||||||
</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 ? (
|
||||||
|
|||||||
110
src/lib/calendar-documents.ts
Normal file
110
src/lib/calendar-documents.ts
Normal 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[],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user