Files
4ventures-client-portal/src/lib/creatives-drive.ts
4Ventures 3cb3ad1db1 feat: Zona de marca tipo Estilo/Brief + brandbooks desde ads/RRSS
UI con nav lateral numerada y panel editable; parse de brandbook JSON string;
enrich desde Meta/Google/IG/FB/web/CSS para que app.4ventures.es muestre el kit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 03:24:45 +02:00

434 lines
12 KiB
TypeScript

/**
* 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<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: 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 };
}