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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
80
src/app/api/change-history/route.ts
Normal file
80
src/app/api/change-history/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
29
src/app/dashboard/historial/page.tsx
Normal file
29
src/app/dashboard/historial/page.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user