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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
318
src/components/ChangeHistoryView.tsx
Normal file
318
src/components/ChangeHistoryView.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
History,
|
||||
RefreshCw,
|
||||
User,
|
||||
Calendar,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
tenant_id: string | null;
|
||||
tenant_name: string | null;
|
||||
user_email: string;
|
||||
user_role: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
detail: Record<string, unknown>;
|
||||
source: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
brandbook: 'Zona de marca',
|
||||
creative: 'Creatividad',
|
||||
calendar: 'Calendario',
|
||||
tenant: 'Cliente',
|
||||
document: 'Documento',
|
||||
enrichment: 'Enriquecimiento',
|
||||
};
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function entityLabel(t: string): string {
|
||||
return ENTITY_LABELS[t] || t;
|
||||
}
|
||||
|
||||
function DetailBlock({ detail }: { detail: Record<string, unknown> }) {
|
||||
const changes = Array.isArray(detail.changes)
|
||||
? (detail.changes as { field?: string; from?: unknown; to?: unknown }[])
|
||||
: null;
|
||||
const fields = Array.isArray(detail.filled_fields)
|
||||
? (detail.filled_fields as string[])
|
||||
: null;
|
||||
const sources = Array.isArray(detail.sources)
|
||||
? (detail.sources as string[])
|
||||
: null;
|
||||
|
||||
if (changes?.length) {
|
||||
return (
|
||||
<ul className="space-y-1.5 text-xs text-gray-600">
|
||||
{changes.map((c, i) => (
|
||||
<li
|
||||
key={`${c.field}-${i}`}
|
||||
className="rounded-lg bg-gray-50 border border-gray-100 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="font-semibold text-gray-800">{c.field}</span>
|
||||
<div className="mt-0.5 grid gap-0.5 sm:grid-cols-2">
|
||||
<span className="text-gray-400 line-through break-all">
|
||||
{c.from == null || c.from === ''
|
||||
? '∅'
|
||||
: typeof c.from === 'string'
|
||||
? c.from
|
||||
: JSON.stringify(c.from)}
|
||||
</span>
|
||||
<span className="text-emerald-700 break-all">
|
||||
{c.to == null || c.to === ''
|
||||
? '∅'
|
||||
: typeof c.to === 'string'
|
||||
? c.to
|
||||
: JSON.stringify(c.to)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (fields?.length || sources?.length) {
|
||||
return (
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
{sources?.length ? (
|
||||
<p>
|
||||
<span className="font-medium text-gray-800">Fuentes:</span>{' '}
|
||||
{sources.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{fields?.length ? (
|
||||
<p>
|
||||
<span className="font-medium text-gray-800">Campos:</span>{' '}
|
||||
{fields.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{typeof detail.note === 'string' ? (
|
||||
<p className="text-gray-500">{detail.note}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const keys = Object.keys(detail || {});
|
||||
if (!keys.length) {
|
||||
return <p className="text-xs text-gray-400">Sin detalle adicional.</p>;
|
||||
}
|
||||
return (
|
||||
<pre className="text-[11px] text-gray-600 bg-gray-50 border border-gray-100 rounded-lg p-2.5 overflow-x-auto max-h-48">
|
||||
{JSON.stringify(detail, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChangeHistoryView({
|
||||
isAgency,
|
||||
initialTenantId,
|
||||
}: {
|
||||
isAgency: boolean;
|
||||
initialTenantId?: string | null;
|
||||
}) {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsMigration, setNeedsMigration] = useState(false);
|
||||
const [entityType, setEntityType] = useState('');
|
||||
const [portfolioWide, setPortfolioWide] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
q.set('limit', '80');
|
||||
if (entityType) q.set('entity_type', entityType);
|
||||
if (initialTenantId) q.set('tenant_id', initialTenantId);
|
||||
const r = await fetch(`/api/change-history?${q}`, { cache: 'no-store' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || j.ok === false) {
|
||||
setNeedsMigration(Boolean(j.needsMigration));
|
||||
setError(j.error || 'No se pudo cargar el historial');
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
setNeedsMigration(false);
|
||||
setRows(j.rows || []);
|
||||
setPortfolioWide(Boolean(j.portfolio_wide));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error de red');
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, initialTenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Auditoría"
|
||||
title="Historial de cambios"
|
||||
description="Usuario, fecha, cambio y detalle de lo que se modifica en el Centro de Mando."
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface className="flex flex-wrap items-center gap-3">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<label className="text-xs font-medium text-gray-600">Tipo</label>
|
||||
<select
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-800"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="brandbook">Zona de marca</option>
|
||||
<option value="enrichment">Enriquecimiento</option>
|
||||
<option value="creative">Creatividades</option>
|
||||
<option value="calendar">Calendario</option>
|
||||
<option value="tenant">Cliente</option>
|
||||
</select>
|
||||
{isAgency ? (
|
||||
<p className="text-xs text-gray-500 ml-auto">
|
||||
{portfolioWide
|
||||
? 'Vista portafolio (todos los clientes)'
|
||||
: 'Filtrado por el cliente del contexto'}
|
||||
</p>
|
||||
) : null}
|
||||
</Surface>
|
||||
|
||||
{needsMigration ? (
|
||||
<Surface className="border-amber-200 bg-amber-50">
|
||||
<p className="text-sm text-amber-900">
|
||||
Falta aplicar la migración SQL{' '}
|
||||
<code className="text-xs bg-amber-100 px-1 rounded">
|
||||
sql/change_history.sql
|
||||
</code>{' '}
|
||||
en Supabase. Hasta entonces no se pueden guardar ni listar cambios.
|
||||
</p>
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
{error && !needsMigration ? (
|
||||
<Surface className="border-red-200 bg-red-50">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</Surface>
|
||||
) : null}
|
||||
|
||||
<Surface padded={false}>
|
||||
{loading && !rows.length ? (
|
||||
<div className="p-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Cargando historial…
|
||||
</div>
|
||||
) : !rows.length ? (
|
||||
<div className="p-6">
|
||||
<EmptyState
|
||||
title="Aún no hay cambios registrados"
|
||||
description="Cuando guardes Zona de marca, enriquezcas un brandbook u otras acciones instrumentadas, aparecerán aquí."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{rows.map((row) => {
|
||||
const open = openId === row.id;
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(open ? null : row.id)}
|
||||
className="w-full text-left px-4 md:px-5 py-3.5 hover:bg-gray-50/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 text-gray-400">
|
||||
{open ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 font-medium text-gray-700">
|
||||
<User className="w-3 h-3" />
|
||||
{row.user_email}
|
||||
{row.user_role ? (
|
||||
<span className="text-gray-400">
|
||||
· {row.user_role}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{fmtDate(row.created_at)}
|
||||
</span>
|
||||
{row.tenant_name ? (
|
||||
<span className="rounded-full bg-brand-50 text-brand-700 px-2 py-0.5 font-medium">
|
||||
{row.tenant_name}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-gray-600">
|
||||
<FileText className="w-3 h-3" />
|
||||
{entityLabel(row.entity_type)} · {row.action}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{row.summary}
|
||||
</p>
|
||||
</div>
|
||||
<History className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
|
||||
</div>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="px-4 md:px-5 pb-4 pl-11 md:pl-12">
|
||||
<DetailBlock detail={row.detail || {}} />
|
||||
{row.source ? (
|
||||
<p className="mt-2 text-[11px] text-gray-400">
|
||||
Origen: {row.source}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
BookMarked,
|
||||
Sparkles,
|
||||
Images,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
import TenantSwitcher from '@/components/TenantSwitcher';
|
||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||
@@ -282,7 +283,7 @@ export default function DashboardShell({
|
||||
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-500">
|
||||
Agencia OS
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<NavLink
|
||||
item={{
|
||||
href: '/dashboard/agencia',
|
||||
@@ -292,6 +293,15 @@ export default function DashboardShell({
|
||||
}}
|
||||
onNavigate={close}
|
||||
/>
|
||||
<NavLink
|
||||
item={{
|
||||
href: '/dashboard/historial',
|
||||
label: 'Historial de cambios',
|
||||
icon: History,
|
||||
iconClass: 'text-slate-500',
|
||||
}}
|
||||
onNavigate={close}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
171
src/lib/change-history.ts
Normal file
171
src/lib/change-history.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Historial de cambios del Centro de Mando.
|
||||
* Usuario · fecha · cambio · detalle.
|
||||
*/
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
import { supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export type ChangeHistoryActor = {
|
||||
userId?: string | null;
|
||||
email?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
export type ChangeHistoryInput = {
|
||||
tenantId?: string | null;
|
||||
tenantName?: string | null;
|
||||
actor?: ChangeHistoryActor | null;
|
||||
entityType: string;
|
||||
entityId?: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
detail?: Record<string, unknown> | null;
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export type ChangeHistoryRow = {
|
||||
id: string;
|
||||
tenant_id: string | null;
|
||||
tenant_name: string | null;
|
||||
user_id: string | null;
|
||||
user_email: string;
|
||||
user_role: string | null;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
detail: Record<string, unknown>;
|
||||
source: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
function getAdmin() {
|
||||
const url = supabaseUrl();
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!url || !key) return null;
|
||||
return createAdminClient(url, key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
}
|
||||
|
||||
/** Compara objetos planos / brandbook y devuelve campos tocados. */
|
||||
export function diffFields(
|
||||
before: Record<string, unknown> | null | undefined,
|
||||
after: Record<string, unknown> | null | undefined,
|
||||
opts?: { maxFields?: number; ignoreKeys?: string[] }
|
||||
): { field: string; from: unknown; to: unknown }[] {
|
||||
const ignore = new Set(
|
||||
opts?.ignoreKeys || ['updated_at', 'version', 'source_excerpts', 'kit_assets']
|
||||
);
|
||||
const max = opts?.maxFields ?? 40;
|
||||
const a = before || {};
|
||||
const b = after || {};
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
const out: { field: string; from: unknown; to: unknown }[] = [];
|
||||
|
||||
const norm = (v: unknown): string => {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v.trim();
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
if (ignore.has(key)) continue;
|
||||
const from = a[key];
|
||||
const to = b[key];
|
||||
if (norm(from) === norm(to)) continue;
|
||||
// Skip empty→empty
|
||||
if (!norm(from) && !norm(to)) continue;
|
||||
out.push({
|
||||
field: key,
|
||||
from: Array.isArray(from) || (from && typeof from === 'object')
|
||||
? clipJson(from)
|
||||
: from ?? null,
|
||||
to: Array.isArray(to) || (to && typeof to === 'object')
|
||||
? clipJson(to)
|
||||
: to ?? null,
|
||||
});
|
||||
if (out.length >= max) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clipJson(v: unknown, n = 240): unknown {
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
if (s.length <= n) return v;
|
||||
return `${s.slice(0, n)}…`;
|
||||
} catch {
|
||||
return String(v).slice(0, n);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra un cambio. Nunca lanza: si falla la tabla o la red, solo log.
|
||||
*/
|
||||
export async function logChange(input: ChangeHistoryInput): Promise<boolean> {
|
||||
const admin = getAdmin();
|
||||
if (!admin) return false;
|
||||
|
||||
const email =
|
||||
(input.actor?.email || '').trim() ||
|
||||
(input.actor?.userId ? `user:${input.actor.userId}` : 'sistema');
|
||||
|
||||
try {
|
||||
const { error } = await admin.from('change_history').insert({
|
||||
tenant_id: input.tenantId || null,
|
||||
tenant_name: input.tenantName || null,
|
||||
user_id: input.actor?.userId || null,
|
||||
user_email: email,
|
||||
user_role: input.actor?.role || null,
|
||||
entity_type: input.entityType,
|
||||
entity_id: input.entityId || null,
|
||||
action: input.action,
|
||||
summary: input.summary.slice(0, 500),
|
||||
detail: input.detail || {},
|
||||
source: input.source || null,
|
||||
});
|
||||
if (error) {
|
||||
// Tabla aún no creada u otro error — no romper el flujo principal
|
||||
console.warn('[change_history]', error.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[change_history]', e instanceof Error ? e.message : e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChangeHistory(opts: {
|
||||
tenantId?: string | null;
|
||||
entityType?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ rows: ChangeHistoryRow[]; error?: string }> {
|
||||
const admin = getAdmin();
|
||||
if (!admin) return { rows: [], error: 'Sin service role' };
|
||||
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const offset = Math.max(opts.offset ?? 0, 0);
|
||||
|
||||
let q = admin
|
||||
.from('change_history')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (opts.tenantId) q = q.eq('tenant_id', opts.tenantId);
|
||||
if (opts.entityType) q = q.eq('entity_type', opts.entityType);
|
||||
|
||||
const { data, error } = await q;
|
||||
if (error) {
|
||||
return { rows: [], error: error.message };
|
||||
}
|
||||
return { rows: (data || []) as ChangeHistoryRow[] };
|
||||
}
|
||||
Reference in New Issue
Block a user