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