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:
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,
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user