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:
4Ventures
2026-10-07 12:44:07 +02:00
parent 5b895d830b
commit 233d3f202e
3 changed files with 296 additions and 30 deletions

223
src/lib/pipeline-drive.ts Normal file
View 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',
};
}
}