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:
175
src/app/api/brandbook/route.ts
Normal file
175
src/app/api/brandbook/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
159
src/app/api/brandbook/upload/route.ts
Normal file
159
src/app/api/brandbook/upload/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { asPlanObject } from '@/lib/calendar-documents';
|
||||
import { generateMonthPreview } from '@/lib/calendar-generate';
|
||||
import type { RrssMonthPlan } from '@/components/RrssMonthView';
|
||||
import {
|
||||
BRANDBOOK_DOC_TYPE,
|
||||
parseBrandbook,
|
||||
} from '@/lib/brandbook';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
@@ -64,11 +68,19 @@ export async function POST(req: NextRequest) {
|
||||
return String(b.month).localeCompare(String(a.month));
|
||||
});
|
||||
|
||||
const bb = await restGet<{ content?: unknown }[]>(
|
||||
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
|
||||
ctx.tenantId
|
||||
)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=content&order=updated_at.desc&limit=1`
|
||||
);
|
||||
const brandbook = parseBrandbook(bb.data?.[0]?.content);
|
||||
|
||||
const result = await generateMonthPreview({
|
||||
ym: month,
|
||||
slug,
|
||||
label: ctx.label,
|
||||
history,
|
||||
brandbook,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -77,5 +89,6 @@ export async function POST(req: NextRequest) {
|
||||
rationale: result.rationale,
|
||||
source: result.source,
|
||||
brand: slug,
|
||||
brandbook: !!brandbook,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user