diff --git a/src/app/api/creative-pipeline/upload/route.ts b/src/app/api/creative-pipeline/upload/route.ts index 6764801..e7ee146 100644 --- a/src/app/api/creative-pipeline/upload/route.ts +++ b/src/app/api/creative-pipeline/upload/route.ts @@ -9,15 +9,20 @@ import { sanitizeFileName, serviceSupabase, } from '@/lib/assets'; +import { syncPipelineAssetToDrive } from '@/lib/pipeline-drive'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; +export const maxDuration = 90; const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); /** * POST multipart: sube portada o slide de una pieza del pipeline. * 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) { const supabase = await createClient(); @@ -36,13 +41,16 @@ export async function POST(req: NextRequest) { const ctx = await resolveActiveTenant(user); 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 file = form.get('file'); 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); if (!(file instanceof File)) { @@ -80,19 +88,32 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Service role required' }, { status: 503 }); } - // Verifica que la pieza existe y pertenece al tenant activo. const curRes = await fetch( `${supabaseUrl()}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent( 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' } ); const curRows = curRes.ok ? await curRes.json() : []; if (!curRows?.[0]) { 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 storagePath = `${ctx.tenantId}/pipeline/${itemId}/${fileName}`; const buf = Buffer.from(await file.arrayBuffer()); @@ -110,8 +131,8 @@ export async function POST(req: NextRequest) { patch.cover_path = storagePath; patch.cover_mime = mime; } else { - const slides: string[] = Array.isArray(curRows[0].slide_paths) - ? [...curRows[0].slide_paths] + const slides: string[] = Array.isArray(item.slide_paths) + ? [...item.slide_paths] : []; slides[index] = storagePath; patch.slide_paths = slides; @@ -134,8 +155,28 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: await patchRes.text() }, { status: 500 }); } 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({ item: Array.isArray(data) ? data[0] : data, 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, + }, }); } diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index 66cd938..7c370f1 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -195,7 +195,7 @@ export default function CreativePipelineBoard() { file: File, slot: 'cover' | 'slide', index = 0 - ): Promise { + ): Promise<{ error: string | null; driveWarning?: string | null }> { const fd = new FormData(); fd.append('file', file); fd.append('itemId', itemId); @@ -206,8 +206,14 @@ export default function CreativePipelineBoard() { body: fd, }); const data = await res.json(); - if (!res.ok) return data.error || 'Error al subir imagen'; - return null; + if (!res.ok) { + 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() { @@ -398,37 +404,36 @@ export default function CreativePipelineBoard() { } const newId = data.item?.id as string | undefined; + let driveNote: string | null = null; if (newId && coverFile) { - const upErr = await uploadFileToItem(newId, coverFile, 'cover', 0); - if (upErr) { + const up = await uploadFileToItem(newId, coverFile, 'cover', 0); + if (up.error) { 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(); await load(); return; } + if (up.driveWarning) driveNote = up.driveWarning; } if (newId && newFormat === 'carrusel' && slideFiles.length) { for (let i = 0; i < slideFiles.length; i++) { - const upErr = await uploadFileToItem( - newId, - slideFiles[i], - 'slide', - i - ); - if (upErr) { + const up = await uploadFileToItem(newId, slideFiles[i], 'slide', i); + if (up.error) { 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(); await load(); return; } + if (up.driveWarning) driveNote = up.driveWarning; } } resetForm(); + if (driveNote) setWarning(`Creativo en portal OK. Drive: ${driveNote}`); await load(); } finally { setCreating(false); @@ -663,17 +668,14 @@ export default function CreativePipelineBoard() { setBusyId(itemId); setErr(null); try { - const fd = new FormData(); - fd.append('file', file); - fd.append('itemId', itemId); - 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'); + const up = await uploadFileToItem(itemId, file, slot, index); + if (up.error) { + setErr(up.error); return; } + if (up.driveWarning) { + setWarning(`Imagen en portal OK. Drive: ${up.driveWarning}`); + } await load(); } finally { setBusyId(null); diff --git a/src/lib/pipeline-drive.ts b/src/lib/pipeline-drive.ts new file mode 100644 index 0000000..a309861 --- /dev/null +++ b/src/lib/pipeline-drive.ts @@ -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 { + 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 { + 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', + }; + } +}