/** * Drive storage for generated creatives (Ads + RRSS). * * {carpeta-cliente}/ * Creatividades-Ads_{slug}/ * YYYY/MM/ * {slug}_ads_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext * Creatividades-RRSS_{slug}/ * YYYY/MM/ * {slug}_rrss_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].ext * * Legacy RRSS root `Pipeline-creatividades_{slug}` is still accepted for reads/visibility. */ import { slugify } from '@/lib/calendar-import'; import { applyMatches, driveConfigured, ensureChildFolder, getTenantDriveFolderId, listClientFoldersUnderClientes, listFilesInFolder, matchFoldersToTenants, uploadToDrive, getValidAccessToken, } from '@/lib/google-drive'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets'; export type CreativeDriveChannel = 'ads' | 'rrss'; export type CreativeDriveItem = { id: string; title?: string | null; format?: string | null; caption?: string | null; notes?: string | null; scheduled_at?: string | null; created_at?: string | null; angle?: string | null; hook?: string | null; }; function pad2(n: number): string { return String(n).padStart(2, '0'); } export function pieceLocalDate(item: CreativeDriveItem): 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 creativesDriveRootFolderName( channel: CreativeDriveChannel, clientSlug: string ): string { const name = slugify(clientSlug) || 'cliente'; return channel === 'ads' ? `Creatividades-Ads_${name}` : `Creatividades-RRSS_${name}`; } /** Legacy RRSS folder name (pre-split). */ export function legacyRrssPipelineFolderName(clientSlug: string): string { const name = slugify(clientSlug) || 'cliente'; return `Pipeline-creatividades_${name}`; } export function isoDateStamp(d: Date): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** * Concise Drive filename: * {slug}_{ads|rrss}_{formato}_{YYYY-MM-DD}_v{N}[.slide-K].{ext} */ export function buildCreativeDriveFileName(opts: { clientSlug: string; channel: CreativeDriveChannel; format: string; date: Date; version: number; ext: string; slideNumber?: number; }): string { const slug = slugify(opts.clientSlug) || 'cliente'; const format = slugify(opts.format || 'pieza') || 'pieza'; const date = isoDateStamp(opts.date); const ver = Math.max(1, opts.version || 1); const ext = (opts.ext || 'jpg').replace(/^\./, '').toLowerCase(); let base = `${slug}_${opts.channel}_${format}_${date}_v${ver}`; if (opts.slideNumber && opts.slideNumber > 0) { base += `.slide-${opts.slideNumber}`; } return `${base}.${ext}`; } /** Match existing versions for same slug/channel/format/date stem. */ export function nextVersionFromExistingNames( fileNames: string[], opts: { clientSlug: string; channel: CreativeDriveChannel; format: string; date: Date; } ): number { const slug = slugify(opts.clientSlug) || 'cliente'; const format = slugify(opts.format || 'pieza') || 'pieza'; const date = isoDateStamp(opts.date); const prefix = `${slug}_${opts.channel}_${format}_${date}_v`; let max = 0; for (const name of fileNames) { if (!name.startsWith(prefix)) continue; const m = name.slice(prefix.length).match(/^(\d+)/); if (m) max = Math.max(max, Number(m[1])); } return max + 1; } 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: new Uint8Array(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 loadTenant(tenantId: string): Promise<{ slug: string; name: 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 }[]; if (!rows[0]) return null; return { slug: rows[0].slug || rows[0].name || 'cliente', name: rows[0].name || rows[0].slug || 'cliente', }; } export async function resolveOrLinkTenantDriveFolder( tenantId: string, clientLabel: string ): Promise<{ folderId: string; folderName?: string; autoLinked?: boolean } | null> { const linked = await getTenantDriveFolderId(tenantId); if (linked?.folderId) return linked; const tenant = await loadTenant(tenantId); if (!tenant) return null; const { folders } = await listClientFoldersUnderClientes(); if (!folders.length) return null; const matches = await matchFoldersToTenants( [{ id: tenantId, name: tenant.name || clientLabel, slug: tenant.slug }], folders ); const hit = matches.find((m) => m.status === 'matched' && m.folderId); if (!hit?.folderId) return null; await applyMatches([hit]); return { folderId: hit.folderId, folderName: hit.folderName || undefined, autoLinked: true, }; } export async function syncCreativeAssetToDrive(opts: { tenantId: string; clientLabel: string; channel: CreativeDriveChannel; item: CreativeDriveItem; buffer: Buffer; mimeType: string; ext: string; slot?: 'cover' | 'slide' | 'asset'; slideIndex?: number; }): Promise<{ ok: boolean; skipped?: boolean; warning?: string; driveFileId?: string; drivePath?: string; fileName?: string; autoLinked?: boolean; }> { if (!driveConfigured()) { return { ok: false, skipped: true, warning: 'Google Drive no configurado (OAuth agencia)', }; } let linked: { folderId: string; folderName?: string; autoLinked?: boolean; } | null = null; try { linked = await resolveOrLinkTenantDriveFolder( opts.tenantId, opts.clientLabel ); } catch (e) { return { ok: false, warning: e instanceof Error ? `Drive auto-link: ${e.message}` : 'Drive auto-link falló', }; } if (!linked?.folderId) { return { ok: false, skipped: true, warning: 'Cliente sin carpeta Drive. Empareja Drive en Agencia (o crea carpeta bajo 03-Clientes).', }; } try { const tenant = await loadTenant(opts.tenantId); const slug = tenant?.slug || opts.clientLabel || 'cliente'; const rootName = creativesDriveRootFolderName(opts.channel, slug); const d = pieceLocalDate(opts.item); const year = String(d.getFullYear()); const month = pad2(d.getMonth() + 1); const format = opts.item.format || 'pieza'; const rootId = await ensureChildFolder(linked.folderId, rootName); const yearId = await ensureChildFolder(rootId, year); const monthId = await ensureChildFolder(yearId, month); const existing = await listFilesInFolder(monthId); const version = nextVersionFromExistingNames( existing.map((f) => f.name), { clientSlug: slug, channel: opts.channel, format, date: d, } ); let slideNumber: number | undefined; if ((format || '').toLowerCase() === 'carrusel') { const slot = opts.slot || 'cover'; const idx = opts.slideIndex || 0; slideNumber = slot === 'cover' ? 1 : Math.max(2, idx + 2); } const fileName = buildCreativeDriveFileName({ clientSlug: slug, channel: opts.channel, format, date: d, version, 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: `${linked.folderName || 'cliente'}/${rootName}/${year}/${month}/${fileName}`, autoLinked: linked.autoLinked, }; } catch (e) { return { ok: false, warning: e instanceof Error ? e.message : 'Error al subir a Drive', }; } } /** Ensure both Ads + RRSS creatives folders exist under the client root. */ export async function ensureCreativesDriveFolders( tenantId: string, clientLabel: string ): Promise<{ ok: boolean; ads?: string; rrss?: string; warning?: string; }> { if (!driveConfigured()) { return { ok: false, warning: 'Drive no configurado' }; } const linked = await resolveOrLinkTenantDriveFolder(tenantId, clientLabel); if (!linked?.folderId) { return { ok: false, warning: 'Sin carpeta cliente en Drive' }; } const tenant = await loadTenant(tenantId); const slug = tenant?.slug || clientLabel; const adsName = creativesDriveRootFolderName('ads', slug); const rrssName = creativesDriveRootFolderName('rrss', slug); const adsId = await ensureChildFolder(linked.folderId, adsName); const rrssId = await ensureChildFolder(linked.folderId, rrssName); return { ok: true, ads: adsId, rrss: rrssId }; } export async function backfillCreativeItemToDrive(opts: { tenantId: string; clientLabel: string; channel: CreativeDriveChannel; item: CreativeDriveItem & { cover_path?: string | null; slide_paths?: unknown; storage_path?: string | null; }; }): Promise<{ ok: boolean; results: { slot: string; ok: boolean; detail: string }[]; }> { const svc = serviceSupabase(); const results: { slot: string; ok: boolean; detail: string }[] = []; if (!svc) { return { ok: false, results: [{ slot: '-', ok: false, detail: 'no service' }] }; } const jobs: { path: string; slot: 'cover' | 'slide' | 'asset'; index: number }[] = []; if (opts.channel === 'ads' && opts.item.storage_path) { jobs.push({ path: opts.item.storage_path, slot: 'asset', index: 0 }); } if (opts.item.cover_path && !/^https?:\/\//i.test(opts.item.cover_path)) { jobs.push({ path: opts.item.cover_path, slot: 'cover', index: 0 }); } const slides = Array.isArray(opts.item.slide_paths) ? (opts.item.slide_paths as string[]) : []; slides.forEach((p, i) => { if (p && !/^https?:\/\//i.test(p)) { jobs.push({ path: p, slot: 'slide', index: i }); } }); for (const job of jobs) { const dl = await svc.storage.from(ASSETS_BUCKET).download(job.path); if (dl.error || !dl.data) { results.push({ slot: job.slot, ok: false, detail: dl.error?.message || 'download fail', }); continue; } const ab = await dl.data.arrayBuffer(); const buf = Buffer.from(ab); const ext = job.path.split('.').pop()?.toLowerCase() || (dl.data.type.includes('png') ? 'png' : 'jpg'); const mime = dl.data.type || 'image/jpeg'; const sync = await syncCreativeAssetToDrive({ tenantId: opts.tenantId, clientLabel: opts.clientLabel, channel: opts.channel, item: opts.item, buffer: buf, mimeType: mime, ext, slot: job.slot, slideIndex: job.index, }); results.push({ slot: `${job.slot}${job.slot === 'slide' ? job.index + 1 : ''}`, ok: sync.ok, detail: sync.ok ? sync.drivePath || 'ok' : sync.warning || 'fail', }); } return { ok: results.every((r) => r.ok), results }; }