Mirror pipeline creatives into each client's Drive folder tree.
On upload, create Pipeline-creatividades_{slug}/YYYY/MM under the linked client folder and name files DD-MM-YYYY_formato_hook[-N].
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,15 +9,20 @@ import {
|
|||||||
sanitizeFileName,
|
sanitizeFileName,
|
||||||
serviceSupabase,
|
serviceSupabase,
|
||||||
} from '@/lib/assets';
|
} from '@/lib/assets';
|
||||||
|
import { syncPipelineAssetToDrive } from '@/lib/pipeline-drive';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
export const maxDuration = 90;
|
||||||
|
|
||||||
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST multipart: sube portada o slide de una pieza del pipeline.
|
* POST multipart: sube portada o slide de una pieza del pipeline.
|
||||||
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
* fields: file, itemId (requerido), slot ('cover' | 'slide'), index? (para slide, 0-based)
|
||||||
|
*
|
||||||
|
* También espeja el archivo en Drive del cliente:
|
||||||
|
* {carpeta-cliente}/Pipeline-creatividades_{slug}/YYYY/MM/DD-MM-YYYY_formato_hook[-N].ext
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -36,13 +41,16 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const ctx = await resolveActiveTenant(user);
|
const ctx = await resolveActiveTenant(user);
|
||||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
return NextResponse.json({ error: 'Selecciona un cliente en Contexto' }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
const file = form.get('file');
|
const file = form.get('file');
|
||||||
const itemId = String(form.get('itemId') || '').trim();
|
const itemId = String(form.get('itemId') || '').trim();
|
||||||
const slot = String(form.get('slot') || 'cover').trim();
|
const slot = String(form.get('slot') || 'cover').trim() as 'cover' | 'slide';
|
||||||
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
|
const index = Math.max(0, parseInt(String(form.get('index') || '0'), 10) || 0);
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
@@ -80,19 +88,32 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verifica que la pieza existe y pertenece al tenant activo.
|
|
||||||
const curRes = await fetch(
|
const curRes = await fetch(
|
||||||
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
`${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(
|
||||||
itemId
|
itemId
|
||||||
)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=id,slide_paths`,
|
)}&tenant_id=eq.${encodeURIComponent(
|
||||||
|
ctx.tenantId
|
||||||
|
)}&select=id,title,format,caption,notes,scheduled_at,created_at,slide_paths`,
|
||||||
{ headers, cache: 'no-store' }
|
{ headers, cache: 'no-store' }
|
||||||
);
|
);
|
||||||
const curRows = curRes.ok ? await curRes.json() : [];
|
const curRows = curRes.ok ? await curRes.json() : [];
|
||||||
if (!curRows?.[0]) {
|
if (!curRows?.[0]) {
|
||||||
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
return NextResponse.json({ error: 'Pieza no encontrada' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
const item = curRows[0] as {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
scheduled_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
slide_paths?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const ext = (sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg').toLowerCase();
|
const ext = (
|
||||||
|
sanitizeFileName(file.name || 'img.jpg').split('.').pop() || 'jpg'
|
||||||
|
).toLowerCase();
|
||||||
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
|
const fileName = slot === 'cover' ? `cover.${ext}` : `slide-${index + 1}.${ext}`;
|
||||||
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
|
const storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`;
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
@@ -110,8 +131,8 @@ export async function POST(req: NextRequest) {
|
|||||||
patch.cover_path = storagePath;
|
patch.cover_path = storagePath;
|
||||||
patch.cover_mime = mime;
|
patch.cover_mime = mime;
|
||||||
} else {
|
} else {
|
||||||
const slides: string[] = Array.isArray(curRows[0].slide_paths)
|
const slides: string[] = Array.isArray(item.slide_paths)
|
||||||
? [...curRows[0].slide_paths]
|
? [...item.slide_paths]
|
||||||
: [];
|
: [];
|
||||||
slides[index] = storagePath;
|
slides[index] = storagePath;
|
||||||
patch.slide_paths = slides;
|
patch.slide_paths = slides;
|
||||||
@@ -134,8 +155,28 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
|
return NextResponse.json({ error: await patchRes.text() }, { status: 500 });
|
||||||
}
|
}
|
||||||
const data = await patchRes.json();
|
const data = await patchRes.json();
|
||||||
|
|
||||||
|
const drive = await syncPipelineAssetToDrive({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
clientLabel: ctx.label,
|
||||||
|
item,
|
||||||
|
buffer: buf,
|
||||||
|
mimeType: mime,
|
||||||
|
ext,
|
||||||
|
slot: slot === 'slide' ? 'slide' : 'cover',
|
||||||
|
slideIndex: index,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
item: Array.isArray(data) ? data[0] : data,
|
item: Array.isArray(data) ? data[0] : data,
|
||||||
storage_path: storagePath,
|
storage_path: storagePath,
|
||||||
|
drive: {
|
||||||
|
ok: drive.ok,
|
||||||
|
skipped: drive.skipped || false,
|
||||||
|
path: drive.drivePath || null,
|
||||||
|
fileName: drive.fileName || null,
|
||||||
|
fileId: drive.driveFileId || null,
|
||||||
|
warning: drive.warning || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export default function CreativePipelineBoard() {
|
|||||||
file: File,
|
file: File,
|
||||||
slot: 'cover' | 'slide',
|
slot: 'cover' | 'slide',
|
||||||
index = 0
|
index = 0
|
||||||
): Promise<string | null> {
|
): Promise<{ error: string | null; driveWarning?: string | null }> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
fd.append('itemId', itemId);
|
fd.append('itemId', itemId);
|
||||||
@@ -206,8 +206,14 @@ export default function CreativePipelineBoard() {
|
|||||||
body: fd,
|
body: fd,
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) return data.error || 'Error al subir imagen';
|
if (!res.ok) {
|
||||||
return null;
|
return { error: data.error || 'Error al subir imagen' };
|
||||||
|
}
|
||||||
|
const driveWarning =
|
||||||
|
data.drive && !data.drive.ok
|
||||||
|
? data.drive.warning || 'No se pudo espejar en Drive'
|
||||||
|
: null;
|
||||||
|
return { error: null, driveWarning };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -398,37 +404,36 @@ export default function CreativePipelineBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newId = data.item?.id as string | undefined;
|
const newId = data.item?.id as string | undefined;
|
||||||
|
let driveNote: string | null = null;
|
||||||
if (newId && coverFile) {
|
if (newId && coverFile) {
|
||||||
const upErr = await uploadFileToItem(newId, coverFile, 'cover', 0);
|
const up = await uploadFileToItem(newId, coverFile, 'cover', 0);
|
||||||
if (upErr) {
|
if (up.error) {
|
||||||
setErr(
|
setErr(
|
||||||
`Pieza creada, pero falló la portada: ${upErr}. Puedes subirla desde la tarjeta.`
|
`Pieza creada, pero falló la portada: ${up.error}. Puedes subirla desde la tarjeta.`
|
||||||
);
|
);
|
||||||
resetForm();
|
resetForm();
|
||||||
await load();
|
await load();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (up.driveWarning) driveNote = up.driveWarning;
|
||||||
}
|
}
|
||||||
if (newId && newFormat === 'carrusel' && slideFiles.length) {
|
if (newId && newFormat === 'carrusel' && slideFiles.length) {
|
||||||
for (let i = 0; i < slideFiles.length; i++) {
|
for (let i = 0; i < slideFiles.length; i++) {
|
||||||
const upErr = await uploadFileToItem(
|
const up = await uploadFileToItem(newId, slideFiles[i], 'slide', i);
|
||||||
newId,
|
if (up.error) {
|
||||||
slideFiles[i],
|
|
||||||
'slide',
|
|
||||||
i
|
|
||||||
);
|
|
||||||
if (upErr) {
|
|
||||||
setErr(
|
setErr(
|
||||||
`Pieza creada; error en slide ${i + 1}: ${upErr}. Completa el resto en la tarjeta.`
|
`Pieza creada; error en slide ${i + 1}: ${up.error}. Completa el resto en la tarjeta.`
|
||||||
);
|
);
|
||||||
resetForm();
|
resetForm();
|
||||||
await load();
|
await load();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (up.driveWarning) driveNote = up.driveWarning;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
|
if (driveNote) setWarning(`Creativo en portal OK. Drive: ${driveNote}`);
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
@@ -663,17 +668,14 @@ export default function CreativePipelineBoard() {
|
|||||||
setBusyId(itemId);
|
setBusyId(itemId);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const up = await uploadFileToItem(itemId, file, slot, index);
|
||||||
fd.append('file', file);
|
if (up.error) {
|
||||||
fd.append('itemId', itemId);
|
setErr(up.error);
|
||||||
fd.append('slot', slot);
|
|
||||||
fd.append('index', String(index));
|
|
||||||
const res = await fetch('/api/creative-pipeline/upload', { method: 'POST', body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
setErr(data.error || 'Error al subir imagen');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (up.driveWarning) {
|
||||||
|
setWarning(`Imagen en portal OK. Drive: ${up.driveWarning}`);
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
|
|||||||
223
src/lib/pipeline-drive.ts
Normal file
223
src/lib/pipeline-drive.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Mirror creative_pipeline uploads into the client's Google Drive folder:
|
||||||
|
*
|
||||||
|
* {carpeta-cliente}/
|
||||||
|
* Pipeline-creatividades_{nombre-cliente}/
|
||||||
|
* YYYY/
|
||||||
|
* MM/
|
||||||
|
* DD-MM-YYYY_formato_hook[-N].ext
|
||||||
|
*/
|
||||||
|
import { slugify } from '@/lib/calendar-import';
|
||||||
|
import {
|
||||||
|
driveConfigured,
|
||||||
|
ensureChildFolder,
|
||||||
|
getTenantDriveFolderId,
|
||||||
|
listFilesInFolder,
|
||||||
|
uploadToDrive,
|
||||||
|
getValidAccessToken,
|
||||||
|
} from '@/lib/google-drive';
|
||||||
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
|
export type PipelineDriveItem = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
scheduled_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer scheduled local date; fallback created_at / today. */
|
||||||
|
export function pieceLocalDate(item: PipelineDriveItem): Date {
|
||||||
|
const raw = item.scheduled_at || item.created_at;
|
||||||
|
if (raw) {
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hookLabel(item: PipelineDriveItem): string {
|
||||||
|
const caption = (item.caption || '').trim();
|
||||||
|
if (caption) {
|
||||||
|
const first = caption.split(/\n/)[0].trim();
|
||||||
|
if (first) return first.slice(0, 80);
|
||||||
|
}
|
||||||
|
const title = (item.title || '').trim();
|
||||||
|
if (title) return title.slice(0, 80);
|
||||||
|
return 'creativo';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDriveFileName(opts: {
|
||||||
|
item: PipelineDriveItem;
|
||||||
|
ext: string;
|
||||||
|
/** 1-based slide number for carrusel; omit for single creatives */
|
||||||
|
slideNumber?: number;
|
||||||
|
}): string {
|
||||||
|
const d = pieceLocalDate(opts.item);
|
||||||
|
const dd = pad2(d.getDate());
|
||||||
|
const mm = pad2(d.getMonth() + 1);
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const format = slugify(opts.item.format || 'post') || 'post';
|
||||||
|
const hook = slugify(hookLabel(opts.item)) || 'creativo';
|
||||||
|
const base = `${dd}-${mm}-${yyyy}_${format}_${hook}`;
|
||||||
|
const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase();
|
||||||
|
if (opts.slideNumber && opts.slideNumber > 0) {
|
||||||
|
return `${base}-${opts.slideNumber}.${ext}`;
|
||||||
|
}
|
||||||
|
return `${base}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pipelineRootFolderName(clientLabel: string): string {
|
||||||
|
const name = slugify(clientLabel) || 'cliente';
|
||||||
|
return `Pipeline-creatividades_${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceDriveFileContent(
|
||||||
|
fileId: string,
|
||||||
|
mimeType: string,
|
||||||
|
buffer: Buffer
|
||||||
|
): Promise<void> {
|
||||||
|
const token = await getValidAccessToken();
|
||||||
|
const r = await fetch(
|
||||||
|
`https://www.googleapis.com/upload/drive/v3/files/${encodeURIComponent(
|
||||||
|
fileId
|
||||||
|
)}?uploadType=media&supportsAllDrives=true`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
body: buffer,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(`Drive update: ${(await r.text()).slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadOrReplace(opts: {
|
||||||
|
parentFolderId: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}): Promise<{ id: string; webViewLink?: string; replaced: boolean }> {
|
||||||
|
const existing = await listFilesInFolder(opts.parentFolderId);
|
||||||
|
const hit = existing.find(
|
||||||
|
(f) =>
|
||||||
|
f.name === opts.fileName &&
|
||||||
|
f.mimeType !== 'application/vnd.google-apps.folder'
|
||||||
|
);
|
||||||
|
if (hit) {
|
||||||
|
await replaceDriveFileContent(hit.id, opts.mimeType, opts.buffer);
|
||||||
|
return { id: hit.id, webViewLink: hit.webViewLink, replaced: true };
|
||||||
|
}
|
||||||
|
const created = await uploadToDrive(opts);
|
||||||
|
return { ...created, replaced: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenantSlug(tenantId: string): Promise<string | null> {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
if (!headers) return null;
|
||||||
|
const r = await fetch(
|
||||||
|
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(
|
||||||
|
tenantId
|
||||||
|
)}&select=slug,name&limit=1`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const rows = (await r.json()) as { slug?: string; name?: string }[];
|
||||||
|
return rows[0]?.slug || rows[0]?.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure month folder under client's Drive root and upload the creative.
|
||||||
|
* Non-fatal if Drive is not linked — returns warning.
|
||||||
|
*/
|
||||||
|
export async function syncPipelineAssetToDrive(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
clientLabel: string;
|
||||||
|
item: PipelineDriveItem;
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
ext: string;
|
||||||
|
slot: 'cover' | 'slide';
|
||||||
|
slideIndex: number;
|
||||||
|
}): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
warning?: string;
|
||||||
|
driveFileId?: string;
|
||||||
|
drivePath?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}> {
|
||||||
|
if (!driveConfigured()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
warning: 'Google Drive no configurado (OAuth agencia)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const linked = await getTenantDriveFolderId(opts.tenantId);
|
||||||
|
if (!linked?.folderId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
skipped: true,
|
||||||
|
warning:
|
||||||
|
'Cliente sin carpeta Drive asociada. Empareja Drive en Agencia / ficha.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slug =
|
||||||
|
(await loadTenantSlug(opts.tenantId)) || opts.clientLabel || 'cliente';
|
||||||
|
const rootName = pipelineRootFolderName(slug);
|
||||||
|
const d = pieceLocalDate(opts.item);
|
||||||
|
const year = String(d.getFullYear());
|
||||||
|
const month = pad2(d.getMonth() + 1);
|
||||||
|
|
||||||
|
const pipelineRootId = await ensureChildFolder(linked.folderId, rootName);
|
||||||
|
const yearId = await ensureChildFolder(pipelineRootId, year);
|
||||||
|
const monthId = await ensureChildFolder(yearId, month);
|
||||||
|
|
||||||
|
const isCarousel = (opts.item.format || '').toLowerCase() === 'carrusel';
|
||||||
|
let slideNumber: number | undefined;
|
||||||
|
if (isCarousel) {
|
||||||
|
// cover → 1; slide index 0 → 2, etc.
|
||||||
|
slideNumber =
|
||||||
|
opts.slot === 'cover' ? 1 : Math.max(2, opts.slideIndex + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = formatDriveFileName({
|
||||||
|
item: opts.item,
|
||||||
|
ext: opts.ext,
|
||||||
|
slideNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
const up = await uploadOrReplace({
|
||||||
|
parentFolderId: monthId,
|
||||||
|
fileName,
|
||||||
|
mimeType: opts.mimeType,
|
||||||
|
buffer: opts.buffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
driveFileId: up.id,
|
||||||
|
fileName,
|
||||||
|
drivePath: `${rootName}/${year}/${month}/${fileName}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
warning: e instanceof Error ? e.message : 'Error al subir a Drive',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user