Expand Zona de marca into a full brand kit with asset uploads.

Expert sections plus logos, fonts, color tokens and file kit so ads, calendars and B2B content share one brand system.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 01:35:08 +02:00
parent e49111f63e
commit 1715b0ac67
3 changed files with 1862 additions and 238 deletions

View File

@@ -0,0 +1,300 @@
/**
* POST multipart — upload logo/font/image/doc into brand kit + link in brandbook.
* Fields: file, kind, role?, label?
* DELETE ?assetId= — unlink + optional delete file
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
ASSETS_BUCKET,
MAX_ASSET_BYTES,
ensureAssetsBucket,
sanitizeFileName,
serviceSupabase,
} from '@/lib/assets';
import {
BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE,
BRANDBOOK_FOLDER,
brandbookCompleteness,
emptyBrandbook,
newId,
parseBrandbook,
type BrandAsset,
type BrandAssetKind,
type Brandbook,
} from '@/lib/brandbook';
import { createHash, randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
const KINDS = new Set<BrandAssetKind>([
'logo',
'font',
'image',
'icon',
'document',
'pattern',
'video',
'other',
]);
async function loadBook(tenantId: string, label: string) {
const r = await restGet<{ id: string; content: unknown }[]>(
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`
);
const row = r.data?.[0];
return {
id: row?.id || null,
book: parseBrandbook(row?.content) || emptyBrandbook(label),
};
}
async function saveBook(
tenantId: string,
docId: string | null,
book: Brandbook
) {
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) throw new Error('No service role');
book.updated_at = new Date().toISOString();
if (docId) {
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${docId}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
content: book,
title: BRANDBOOK_DOC_TITLE,
updated_at: book.updated_at,
}),
});
if (!patch.ok) throw new Error(await patch.text());
return docId;
}
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
tenant_id: tenantId,
type: BRANDBOOK_DOC_TYPE,
title: BRANDBOOK_DOC_TITLE,
status: 'approved',
content: book,
}),
});
if (!ins.ok) throw new Error(await ins.text());
const rows = (await ins.json()) as { id: string }[];
return rows[0]?.id || null;
}
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) {
return NextResponse.json(
{ error: `Storage: ${bucketOk.error}` },
{ status: 503 }
);
}
const svc = serviceSupabase();
const headers = serviceHeaders();
if (!svc || !headers) {
return NextResponse.json({ error: 'No service role' }, { status: 503 });
}
const form = await req.formData();
const file = form.get('file');
if (!(file instanceof File) || file.size <= 0) {
return NextResponse.json({ error: 'Archivo requerido' }, { status: 400 });
}
if (file.size > MAX_ASSET_BYTES) {
return NextResponse.json({ error: 'Archivo > 80 MB' }, { status: 400 });
}
const kindRaw = String(form.get('kind') || 'other') as BrandAssetKind;
const kind = KINDS.has(kindRaw) ? kindRaw : 'other';
const role = String(form.get('role') || kind).slice(0, 80);
const label = String(form.get('label') || file.name).slice(0, 120);
const safeName = sanitizeFileName(file.name);
const id = randomUUID();
const folder = `${BRANDBOOK_FOLDER}/${kind}`;
const storagePath = `${ctx.tenantId}/${folder}/${id}_${safeName}`;
const buf = Buffer.from(await file.arrayBuffer());
const sha256 = createHash('sha256').update(buf).digest('hex');
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
contentType: file.type || 'application/octet-stream',
upsert: false,
});
if (up.error) {
return NextResponse.json({ error: up.error.message }, { status: 500 });
}
const row = {
id,
tenant_id: ctx.tenantId,
title: label || safeName,
folder,
file_name: safeName,
mime_type: file.type || 'application/octet-stream',
bytes: file.size,
storage_path: storagePath,
sha256,
status: 'approved',
created_by: user.id,
};
const ins = await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(row),
});
if (!ins.ok) {
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
return NextResponse.json({ error: await ins.text() }, { status: 500 });
}
const asset: BrandAsset = {
id: newId('kit'),
asset_file_id: id,
kind,
role,
label,
file_name: safeName,
mime_type: file.type || undefined,
added_at: new Date().toISOString(),
};
const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label);
book.brand_name = book.brand_name || ctx.label;
book.kit_assets = [asset, ...(book.kit_assets || [])];
// Auto-add font token when uploading a font file
if (kind === 'font') {
const family = label.replace(/\.(ttf|otf|woff2?|zip)$/i, '').trim() || label;
book.fonts_kit = [
{
id: newId('font'),
family,
role: /display|headline|titulo/i.test(role) ? 'headline' : 'body',
weights: '400,700',
source: 'file',
url_or_notes: `Archivo: ${safeName}`,
asset_file_id: id,
},
...(book.fonts_kit || []),
];
}
try {
await saveBook(ctx.tenantId, docId, book);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'No se pudo guardar brandbook' },
{ status: 500 }
);
}
return NextResponse.json({
ok: true,
asset,
brandbook: book,
completeness: brandbookCompleteness(book),
});
}
export async function DELETE(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente' },
{ status: 400 }
);
}
const kitId = req.nextUrl.searchParams.get('kitId') || '';
const fileId = req.nextUrl.searchParams.get('assetFileId') || '';
if (!kitId && !fileId) {
return NextResponse.json({ error: 'kitId requerido' }, { status: 400 });
}
const { id: docId, book } = await loadBook(ctx.tenantId, ctx.label);
const removed = book.kit_assets.find(
(a) => a.id === kitId || a.asset_file_id === fileId
);
book.kit_assets = book.kit_assets.filter(
(a) => a.id !== kitId && a.asset_file_id !== fileId
);
if (removed?.asset_file_id) {
book.fonts_kit = book.fonts_kit.map((f) =>
f.asset_file_id === removed.asset_file_id
? { ...f, asset_file_id: undefined }
: f
);
}
try {
await saveBook(ctx.tenantId, docId, book);
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Error' },
{ status: 500 }
);
}
return NextResponse.json({
ok: true,
brandbook: book,
completeness: brandbookCompleteness(book),
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff