fix(historial): registrar todos los cambios de Zona de marca

Instrumenta kit, fuentes y estilo creativo; el listado filtra por rango e incluye hoy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 14:05:17 +02:00
parent f127b0a4c4
commit ee6e6bace1
6 changed files with 208 additions and 21 deletions

View File

@@ -31,6 +31,7 @@ import {
type BrandAssetKind, type BrandAssetKind,
type Brandbook, type Brandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import { logChange } from '@/lib/change-history';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -254,6 +255,37 @@ export async function POST(req: NextRequest) {
} }
} }
await logChange({
tenantId: ctx.tenantId,
tenantName: ctx.label,
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'brandbook',
entityId: docId,
action: 'kit_upload',
summary: `Kit · subida ${kind}${role ? ` (${role})` : ''} · ${label} · ${ctx.label}`,
detail: {
kind,
role,
label,
file_name: safeName,
asset_id: asset.id,
asset_file_id: id,
changes: [
{
field: 'kit_assets',
from: null,
to: `${kind}: ${label}`,
},
],
revertable: false,
},
source: 'api/brandbook/kit',
});
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
asset, asset,
@@ -311,6 +343,38 @@ export async function DELETE(req: NextRequest) {
); );
} }
await logChange({
tenantId: ctx.tenantId,
tenantName: ctx.label,
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'brandbook',
entityId: docId,
action: 'kit_delete',
summary: `Kit · eliminado ${removed?.kind || 'asset'}${
removed?.label ? ` (${removed.label})` : ''
} · ${ctx.label}`,
detail: {
removed,
kit_id: kitId || null,
asset_file_id: fileId || null,
changes: removed
? [
{
field: 'kit_assets',
from: `${removed.kind}: ${removed.label}`,
to: null,
},
]
: [],
revertable: false,
},
source: 'api/brandbook/kit',
});
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
brandbook: book, brandbook: book,

View File

@@ -19,6 +19,7 @@ import {
detectSourceExt, detectSourceExt,
extractSourceText, extractSourceText,
} from '@/lib/extract-document-text'; } from '@/lib/extract-document-text';
import { logChange } from '@/lib/change-history';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 60; export const maxDuration = 60;
@@ -246,6 +247,33 @@ export async function POST(req: NextRequest) {
} }
} }
await logChange({
tenantId: ctx.tenantId,
tenantName: ctx.label,
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'brandbook',
entityId: id,
action: 'source_upload',
summary: `Fuente · ${filename} · ${ctx.label}`,
detail: {
filename,
chars: text.length,
changes: [
{
field: 'source_excerpts',
from: null,
to: filename,
},
],
revertable: false,
},
source: 'api/brandbook/upload',
});
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
brandbook: next, brandbook: next,

View File

@@ -22,9 +22,11 @@ export async function GET(req: NextRequest) {
const role = userRole(user); const role = userRole(user);
const isAgency = isAgencyRole(role); const isAgency = isAgencyRole(role);
const sp = req.nextUrl.searchParams; const sp = req.nextUrl.searchParams;
const limit = Number(sp.get('limit') || 50); const limit = Number(sp.get('limit') || 100);
const offset = Number(sp.get('offset') || 0); const offset = Number(sp.get('offset') || 0);
const entityType = sp.get('entity_type') || sp.get('entityType') || null; const entityType = sp.get('entity_type') || sp.get('entityType') || null;
const since = sp.get('since') || null;
const until = sp.get('until') || null;
let tenantId = let tenantId =
sp.get('tenant_id') || sp.get('tenantId') || null; sp.get('tenant_id') || sp.get('tenantId') || null;
@@ -46,9 +48,11 @@ export async function GET(req: NextRequest) {
// Si portfolio wide, tenantId queda null → historial global // Si portfolio wide, tenantId queda null → historial global
} }
const { rows, error } = await listChangeHistory({ const { rows, error, total } = await listChangeHistory({
tenantId, tenantId,
entityType, entityType,
since,
until,
limit, limit,
offset, offset,
}); });
@@ -74,7 +78,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
rows, rows,
total: total ?? rows.length,
tenant_id: tenantId, tenant_id: tenantId,
portfolio_wide: isAgency && !tenantId, portfolio_wide: isAgency && !tenantId,
since,
until,
}); });
} }

View File

@@ -14,6 +14,7 @@ import {
seedBrandbook, seedBrandbook,
type Brandbook, type Brandbook,
} from '@/lib/brandbook'; } from '@/lib/brandbook';
import { diffFields, logChange } from '@/lib/change-history';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -206,7 +207,9 @@ export async function PUT(req: NextRequest) {
const admin = getAdmin(); const admin = getAdmin();
const { data: existing, error: getErr } = await admin const { data: existing, error: getErr } = await admin
.from('tenants') .from('tenants')
.select('id, name, slug, settings') .select(
'id, name, slug, settings, creative_style, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products'
)
.eq('id', tenantId) .eq('id', tenantId)
.maybeSingle(); .maybeSingle();
@@ -224,7 +227,7 @@ export async function PUT(req: NextRequest) {
const updatePayload: Record<string, any> = { const updatePayload: Record<string, any> = {
creative_style: normalizedStyle, creative_style: normalizedStyle,
settings: { settings: {
...(existing.settings || {}), ...((existing.settings as Record<string, unknown>) || {}),
creative_style: normalizedStyle, creative_style: normalizedStyle,
}, },
}; };
@@ -273,7 +276,7 @@ export async function PUT(req: NextRequest) {
// Cargar brandbook de documents para compilar skill actualizado // Cargar brandbook de documents para compilar skill actualizado
const { data: brandDoc } = await admin const { data: brandDoc } = await admin
.from('documents') .from('documents')
.select('content') .select('id, content')
.eq('tenant_id', tenantId) .eq('tenant_id', tenantId)
.eq('type', 'brandbook') .eq('type', 'brandbook')
.order('updated_at', { ascending: false }) .order('updated_at', { ascending: false })
@@ -328,14 +331,14 @@ export async function PUT(req: NextRequest) {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}; };
if (brandDoc) { if (brandDoc?.id) {
await admin await admin
.from('documents') .from('documents')
.update({ .update({
content: updatedBrandbook, content: updatedBrandbook,
updated_at: updatedBrandbook.updated_at, updated_at: updatedBrandbook.updated_at,
}) })
.eq('id', (brandDoc as any).id); .eq('id', brandDoc.id);
} else { } else {
await admin await admin
.from('documents') .from('documents')
@@ -349,6 +352,65 @@ export async function PUT(req: NextRequest) {
}); });
} }
const beforeFlat: Record<string, unknown> = {
creatives_enabled: existing.creatives_enabled,
meta_ad_account_id: existing.meta_ad_account_id,
meta_page_id: existing.meta_page_id,
meta_ig_user_id: existing.meta_ig_user_id,
slack_channel_id: existing.slack_channel_id,
drive_folder_id: existing.drive_folder_id,
default_landing_url: existing.default_landing_url,
canva_templates: existing.canva_templates,
campaign_audience: existing.campaign_audience,
campaign_products: existing.campaign_products,
creative_style: existing.creative_style,
};
const afterFlat: Record<string, unknown> = {
creatives_enabled:
updatePayload.creatives_enabled ?? existing.creatives_enabled,
meta_ad_account_id:
updatePayload.meta_ad_account_id ?? existing.meta_ad_account_id,
meta_page_id: updatePayload.meta_page_id ?? existing.meta_page_id,
meta_ig_user_id:
updatePayload.meta_ig_user_id ?? existing.meta_ig_user_id,
slack_channel_id:
updatePayload.slack_channel_id ?? existing.slack_channel_id,
drive_folder_id:
updatePayload.drive_folder_id ?? existing.drive_folder_id,
default_landing_url:
updatePayload.default_landing_url ?? existing.default_landing_url,
canva_templates:
updatePayload.canva_templates ?? existing.canva_templates,
campaign_audience:
updatePayload.campaign_audience ?? existing.campaign_audience,
campaign_products:
updatePayload.campaign_products ?? existing.campaign_products,
creative_style: normalizedStyle,
};
const styleChanges = diffFields(beforeFlat, afterFlat, { maxFields: 30 });
await logChange({
tenantId,
tenantName: existing.name,
actor: {
userId: user.id,
email: user.email,
role,
},
entityType: 'brandbook',
entityId: brandDoc?.id || tenantId,
action: 'creative_style_update',
summary: styleChanges.length
? `Estilo creativo · ${styleChanges.length} campo(s) · ${existing.name}`
: `Estilo creativo guardado · ${existing.name}`,
detail: {
changes: styleChanges,
revertable: false,
synced_brandbook: true,
},
source: 'api/creatives/style',
});
const compiledMarkdown = compileClientSkillFromBrandbook( const compiledMarkdown = compileClientSkillFromBrandbook(
updatedBrandbook, updatedBrandbook,
{ {

View File

@@ -14,6 +14,8 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { PageHeader, Surface, EmptyState } from '@/components/ui'; import { PageHeader, Surface, EmptyState } from '@/components/ui';
import { useDateRange } from '@/components/DateRangeContext';
import { isoToday } from '@/lib/ad-tracker-range';
type Row = { type Row = {
id: string; id: string;
@@ -157,7 +159,11 @@ export default function ChangeHistoryView({
isAgency: boolean; isAgency: boolean;
initialTenantId?: string | null; initialTenantId?: string | null;
}) { }) {
const { since, label: rangeLabel } = useDateRange();
// El date range global de Ads cierra en «ayer»; el historial debe incluir hoy.
const untilInclusive = isoToday();
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [needsMigration, setNeedsMigration] = useState(false); const [needsMigration, setNeedsMigration] = useState(false);
@@ -172,27 +178,32 @@ export default function ChangeHistoryView({
setError(null); setError(null);
try { try {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set('limit', '80'); q.set('limit', '200');
if (entityType) q.set('entity_type', entityType); if (entityType) q.set('entity_type', entityType);
if (initialTenantId) q.set('tenant_id', initialTenantId); if (initialTenantId) q.set('tenant_id', initialTenantId);
if (since) q.set('since', since);
q.set('until', untilInclusive);
const r = await fetch(`/api/change-history?${q}`, { cache: 'no-store' }); const r = await fetch(`/api/change-history?${q}`, { cache: 'no-store' });
const j = await r.json(); const j = await r.json();
if (!r.ok || j.ok === false) { if (!r.ok || j.ok === false) {
setNeedsMigration(Boolean(j.needsMigration)); setNeedsMigration(Boolean(j.needsMigration));
setError(j.error || 'No se pudo cargar el historial'); setError(j.error || 'No se pudo cargar el historial');
setRows([]); setRows([]);
setTotal(0);
return; return;
} }
setNeedsMigration(false); setNeedsMigration(false);
setRows(j.rows || []); setRows(j.rows || []);
setTotal(typeof j.total === 'number' ? j.total : (j.rows || []).length);
setPortfolioWide(Boolean(j.portfolio_wide)); setPortfolioWide(Boolean(j.portfolio_wide));
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error de red'); setError(e instanceof Error ? e.message : 'Error de red');
setRows([]); setRows([]);
setTotal(0);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [entityType, initialTenantId]); }, [entityType, initialTenantId, since, untilInclusive]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -261,13 +272,15 @@ export default function ChangeHistoryView({
<option value="calendar">Calendario</option> <option value="calendar">Calendario</option>
<option value="tenant">Cliente</option> <option value="tenant">Cliente</option>
</select> </select>
{isAgency ? ( <p className="text-xs text-gray-500 ml-auto">
<p className="text-xs text-gray-500 ml-auto"> {rangeLabel ? `${rangeLabel} · ` : ''}
{portfolioWide {total} cambio{total === 1 ? '' : 's'}
? 'Vista portafolio (todos los clientes)' {isAgency
: 'Filtrado por el cliente del contexto'} ? portfolioWide
</p> ? ' · portafolio'
) : null} : ' · cliente del contexto'
: ''}
</p>
</Surface> </Surface>
{needsMigration ? ( {needsMigration ? (

View File

@@ -196,27 +196,40 @@ export async function logChange(input: ChangeHistoryInput): Promise<boolean> {
export async function listChangeHistory(opts: { export async function listChangeHistory(opts: {
tenantId?: string | null; tenantId?: string | null;
entityType?: string | null; entityType?: string | null;
/** Inclusive YYYY-MM-DD (UTC day start) */
since?: string | null;
/** Inclusive YYYY-MM-DD (UTC day end) */
until?: string | null;
limit?: number; limit?: number;
offset?: number; offset?: number;
}): Promise<{ rows: ChangeHistoryRow[]; error?: string }> { }): Promise<{ rows: ChangeHistoryRow[]; error?: string; total?: number }> {
const admin = getAdmin(); const admin = getAdmin();
if (!admin) return { rows: [], error: 'Sin service role' }; if (!admin) return { rows: [], error: 'Sin service role' };
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
const offset = Math.max(opts.offset ?? 0, 0); const offset = Math.max(opts.offset ?? 0, 0);
let q = admin let q = admin
.from('change_history') .from('change_history')
.select('*') .select('*', { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + limit - 1); .range(offset, offset + limit - 1);
if (opts.tenantId) q = q.eq('tenant_id', opts.tenantId); if (opts.tenantId) q = q.eq('tenant_id', opts.tenantId);
if (opts.entityType) q = q.eq('entity_type', opts.entityType); if (opts.entityType) q = q.eq('entity_type', opts.entityType);
if (opts.since && /^\d{4}-\d{2}-\d{2}$/.test(opts.since)) {
q = q.gte('created_at', `${opts.since}T00:00:00.000Z`);
}
if (opts.until && /^\d{4}-\d{2}-\d{2}$/.test(opts.until)) {
q = q.lte('created_at', `${opts.until}T23:59:59.999Z`);
}
const { data, error } = await q; const { data, error, count } = await q;
if (error) { if (error) {
return { rows: [], error: error.message }; return { rows: [], error: error.message };
} }
return { rows: (data || []) as ChangeHistoryRow[] }; return {
rows: (data || []) as ChangeHistoryRow[],
total: typeof count === 'number' ? count : undefined,
};
} }