Add Zona de marca brandbooks and strategist calendar generation.

Per-tenant brandbooks power ads, RRSS calendars, LinkedIn B2B and blog; Nov calendars now create from trends + client DNA instead of copying history.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-10 01:05:15 +02:00
parent 59f42eafe0
commit e49111f63e
13 changed files with 2907 additions and 500 deletions

View File

@@ -0,0 +1,175 @@
/**
* GET / PUT brandbook for active tenant (documents type=brandbook).
*/
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 {
BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE,
brandbookCompleteness,
emptyBrandbook,
parseBrandbook,
seedBrandbook,
type Brandbook,
} from '@/lib/brandbook';
export const dynamic = 'force-dynamic';
async function loadDoc(tenantId: string): Promise<{
id: string | null;
book: Brandbook | null;
}> {
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];
if (!row) return { id: null, book: null };
return { id: row.id, book: parseBrandbook(row.content) };
}
export async function GET() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || slug;
const { id, book } = await loadDoc(ctx.tenantId);
const current = book || emptyBrandbook(ctx.label);
const completeness = brandbookCompleteness(current);
return NextResponse.json({
ok: true,
id,
brandbook: current,
slug,
label: ctx.label,
completeness,
exists: !!book,
});
}
export async function PUT(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 body = (await req.json().catch(() => ({}))) as {
brandbook?: unknown;
seed?: boolean;
};
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || slug;
let book: Brandbook | null = null;
if (body.seed) {
book = seedBrandbook(slug, ctx.label);
} else {
book = parseBrandbook(body.brandbook);
}
if (!book) {
return NextResponse.json({ error: 'brandbook inválido' }, { status: 400 });
}
book.updated_at = new Date().toISOString();
book.brand_name = book.brand_name || ctx.label;
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'No service role' }, { status: 500 });
}
const { id } = await loadDoc(ctx.tenantId);
if (id) {
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
content: book,
title: BRANDBOOK_DOC_TITLE,
updated_at: book.updated_at,
}),
});
if (!patch.ok) {
return NextResponse.json(
{ error: await patch.text() },
{ status: 500 }
);
}
const rows = (await patch.json()) as { id: string }[];
return NextResponse.json({
ok: true,
id: rows[0]?.id || id,
brandbook: book,
completeness: brandbookCompleteness(book),
});
}
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
tenant_id: ctx.tenantId,
type: BRANDBOOK_DOC_TYPE,
title: BRANDBOOK_DOC_TITLE,
status: 'approved',
content: book,
}),
});
if (!ins.ok) {
return NextResponse.json({ error: await ins.text() }, { status: 500 });
}
const rows = (await ins.json()) as { id: string }[];
return NextResponse.json({
ok: true,
id: rows[0]?.id,
brandbook: book,
completeness: brandbookCompleteness(book),
created: true,
});
}

View File

@@ -0,0 +1,159 @@
/**
* POST multipart: file (.txt/.md/.csv) or JSON { text, filename }
* Appends source excerpt into the tenant brandbook.
*/
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 {
BRANDBOOK_DOC_TITLE,
BRANDBOOK_DOC_TYPE,
brandbookCompleteness,
emptyBrandbook,
ingestSourceText,
parseBrandbook,
type Brandbook,
} from '@/lib/brandbook';
export const dynamic = 'force-dynamic';
async function loadBook(tenantId: string, label: string): Promise<{
id: string | null;
book: Brandbook;
}> {
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),
};
}
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 }
);
}
let filename = 'pegado.txt';
let text = '';
const ctype = req.headers.get('content-type') || '';
if (ctype.includes('multipart/form-data')) {
const form = await req.formData();
const file = form.get('file');
const pasted = form.get('text');
if (typeof pasted === 'string' && pasted.trim()) {
text = pasted;
filename = String(form.get('filename') || 'pegado.txt');
} else if (file && typeof file === 'object' && 'arrayBuffer' in file) {
const f = file as File;
filename = f.name || 'upload.txt';
const lower = filename.toLowerCase();
if (
!lower.endsWith('.txt') &&
!lower.endsWith('.md') &&
!lower.endsWith('.csv') &&
!lower.endsWith('.json')
) {
return NextResponse.json(
{
error:
'Por ahora sube .txt, .md, .csv o .json. PDF/DOCX: pega el texto en el campo.',
},
{ status: 400 }
);
}
text = await f.text();
}
} else {
const body = (await req.json().catch(() => ({}))) as {
text?: string;
filename?: string;
};
text = String(body.text || '');
filename = String(body.filename || 'pegado.txt');
}
if (!text.trim()) {
return NextResponse.json({ error: 'Sin texto' }, { status: 400 });
}
if (text.length > 200_000) {
return NextResponse.json(
{ error: 'Texto demasiado largo (máx ~200k)' },
{ status: 400 }
);
}
const { id, book } = await loadBook(ctx.tenantId, ctx.label);
const next = ingestSourceText(book, filename, text);
next.brand_name = next.brand_name || ctx.label;
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'No service role' }, { status: 500 });
}
if (id) {
const patch = await fetch(`${base}/rest/v1/documents?id=eq.${id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
content: next,
title: BRANDBOOK_DOC_TITLE,
updated_at: next.updated_at,
}),
});
if (!patch.ok) {
return NextResponse.json({ error: await patch.text() }, { status: 500 });
}
} else {
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
tenant_id: ctx.tenantId,
type: BRANDBOOK_DOC_TYPE,
title: BRANDBOOK_DOC_TITLE,
status: 'approved',
content: next,
}),
});
if (!ins.ok) {
return NextResponse.json({ error: await ins.text() }, { status: 500 });
}
}
return NextResponse.json({
ok: true,
brandbook: next,
completeness: brandbookCompleteness(next),
ingested: filename,
});
}