feat: historial de cambios (usuario, fecha, detalle)

Tabla change_history, pantalla en Agencia OS y registro automático
al guardar o enriquecer Zona de marca.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-15 15:42:18 +02:00
parent 62a25e7236
commit 39b1f8ec02
9 changed files with 835 additions and 2 deletions

View File

@@ -12,6 +12,7 @@ import {
} from '@/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '@/lib/creatives-drive';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { logChange } from '@/lib/change-history';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
@@ -58,6 +59,29 @@ export async function POST(req: NextRequest) {
await ensureCreativesDriveFolders(r.tenantId, r.slug);
}
}
if (!dryRun) {
await logChange({
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'enrichment',
action: 'enrich_all',
summary: `Enriquecimiento masivo de Zona de marca · ${results.length} marca(s)`,
detail: {
count: results.length,
force_seed: forceSeed,
slug_filter: body.slug || null,
filled: results.map((r) => ({
slug: r.slug,
fields: r.filledFields?.length || 0,
sources: r.sources,
})),
},
source: 'api/brandbook/enrich',
});
}
return NextResponse.json({
ok: true,
count: results.length,
@@ -103,5 +127,31 @@ export async function POST(req: NextRequest) {
drive = await ensureCreativesDriveFolders(result.tenantId, result.slug);
}
if (!dryRun && result.ok) {
await logChange({
tenantId: result.tenantId,
tenantName: result.slug,
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'enrichment',
entityId: result.tenantId,
action: 'enrich',
summary: `Enriquecimiento Zona de marca · ${result.slug} · ${
result.filledFields?.length || 0
} campo(s)`,
detail: {
filled_fields: result.filledFields || [],
sources: result.sources || [],
completeness_before: result.completenessBefore,
completeness_after: result.completenessAfter,
force_seed: forceSeed,
},
source: 'api/brandbook/enrich',
});
}
return NextResponse.json({ ok: result.ok, result, drive });
}

View File

@@ -16,6 +16,7 @@ import {
seedBrandbook,
type Brandbook,
} from '@/lib/brandbook';
import { diffFields, logChange } from '@/lib/change-history';
export const dynamic = 'force-dynamic';
@@ -241,7 +242,8 @@ export async function PUT(req: NextRequest) {
book.brand_name = book.brand_name || label;
const admin = getAdmin();
const { id } = await loadDoc(tenantId);
const { id, book: previousBook } = await loadDoc(tenantId);
const created = !id;
let docId = id;
if (id) {
@@ -277,6 +279,39 @@ export async function PUT(req: NextRequest) {
docId = insRow.id;
}
const changes = body.seed
? [{ field: '_seed', from: null, to: 'brandbook seed aplicado' }]
: diffFields(
previousBook as unknown as Record<string, unknown>,
book as unknown as Record<string, unknown>
);
await logChange({
tenantId,
tenantName: label,
actor: {
userId: user.id,
email: user.email,
role: userRole(user),
},
entityType: 'brandbook',
entityId: docId,
action: body.seed ? 'seed' : created ? 'create' : 'update',
summary: body.seed
? `Seed de Zona de marca · ${label}`
: created
? `Creación de Zona de marca · ${label}`
: changes.length
? `Actualización Zona de marca · ${changes.length} campo(s) · ${label}`
: `Guardado Zona de marca · ${label}`,
detail: {
changes: changes.slice(0, 40),
seed: Boolean(body.seed),
slug,
},
source: 'api/brandbook',
});
// Sincronizar simultáneamente con tenants.creative_style para consistencia 100%
const { data: tenantRow } = await admin
.from('tenants')

View File

@@ -0,0 +1,80 @@
/**
* GET /api/change-history — listado del historial de cambios.
* Query: tenant_id?, entity_type?, limit?, offset?
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { homeTenantId, isAgencyRole, userRole } from '@/lib/agency';
import { listChangeHistory } from '@/lib/change-history';
import { resolveActiveTenant } from '@/lib/tenant-context';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const role = userRole(user);
const isAgency = isAgencyRole(role);
const sp = req.nextUrl.searchParams;
const limit = Number(sp.get('limit') || 50);
const offset = Number(sp.get('offset') || 0);
const entityType = sp.get('entity_type') || sp.get('entityType') || null;
let tenantId =
sp.get('tenant_id') || sp.get('tenantId') || null;
if (!isAgency) {
// Cliente: solo su tenant
const own = homeTenantId(user);
const ctx = await resolveActiveTenant(user);
tenantId = own || ctx.tenantId || null;
if (!tenantId) {
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
}
} else if (!tenantId) {
// Agencia sin filtro: usa contexto activo si hay cliente seleccionado
const ctx = await resolveActiveTenant(user);
if (ctx.tenantId && !ctx.portfolioWide) {
tenantId = ctx.tenantId;
}
// Si portfolio wide, tenantId queda null → historial global
}
const { rows, error } = await listChangeHistory({
tenantId,
entityType,
limit,
offset,
});
if (error) {
const missing =
/relation .*change_history.* does not exist|Could not find the table/i.test(
error
);
return NextResponse.json(
{
ok: false,
error: missing
? 'Falta crear la tabla change_history (sql/change_history.sql).'
: error,
rows: [],
needsMigration: missing,
},
{ status: missing ? 503 : 500 }
);
}
return NextResponse.json({
ok: true,
rows,
tenant_id: tenantId,
portfolio_wide: isAgency && !tenantId,
});
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import ChangeHistoryView from '@/components/ChangeHistoryView';
export const dynamic = 'force-dynamic';
export default async function HistorialPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const role = userRole(user);
const agency = isAgencyRole(role);
const ctx = await resolveActiveTenant(user);
return (
<ChangeHistoryView
isAgency={agency}
initialTenantId={
agency && ctx.tenantId && !ctx.portfolioWide ? ctx.tenantId : null
}
/>
);
}