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>
434 lines
12 KiB
TypeScript
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 };
|
|
}
|