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:
300
src/app/api/brandbook/kit/route.ts
Normal file
300
src/app/api/brandbook/kit/route.ts
Normal 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
Reference in New Issue
Block a user