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>
This commit is contained in:
433
src/lib/creatives-drive.ts
Normal file
433
src/lib/creatives-drive.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user