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,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,
PUBLISH_STATUSES,
} from '@/lib/pipeline-schedule';
import {
asPlanObject,
buildCaption,
mapFormatToPipeline,
piecesFromDocs,
scheduledAtFromPiece,
type CalendarDoc,
} from '@/lib/calendar-documents';
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']);
@@ -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();
if (!headers) {
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`, {
method: 'POST',
headers: {
@@ -189,7 +249,39 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
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) {
@@ -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_mime' in body) clean.cover_mime = str(body.cover_mime, 100);
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.
if ('scheduled_at' in body) {