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

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Apply sql/change_history.sql via Dokploy supabase-db terminal."""
from __future__ import annotations
import base64
import os
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path("/Users/4ventures/4ventures-client-portal")
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
SQL = ROOT / "sql" / "change_history.sql"
BASE = "https://dokploy.4ventures.es"
def env(key: str) -> str:
v = os.environ.get(key, "").strip()
if v:
return v
p = BPS / ".env"
if not p.is_file():
return ""
for line in p.read_text().splitlines():
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
def main() -> int:
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
password = env("DOKPLOY_PASSWORD")
if not password:
print("NO_PASSWORD", flush=True)
return 2
if not SQL.is_file():
print("NO_SQL", flush=True)
return 3
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1400, "height": 1000})
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(800)
if "/dashboard" not in page.url:
page.locator('input[type="email"], input[name="email"]').first.fill(email)
page.locator('input[type="password"]').first.fill(password)
page.locator('button[type="submit"]').first.click()
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
print("login ok", flush=True)
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
page.wait_for_timeout(800)
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
"supabase-db"
)
page.wait_for_timeout(1000)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
page.wait_for_timeout(400)
page.get_by_text("Terminal", exact=True).click()
page.wait_for_timeout(1000)
if page.locator("button", has_text="Bash").count():
page.locator("button", has_text="Bash").first.click()
page.wait_for_timeout(2800)
if page.locator("textarea.xterm-helper-textarea").count():
page.locator("textarea.xterm-helper-textarea").first.click()
def run(cmd: str, wait: int = 800) -> None:
page.keyboard.type(cmd, delay=1)
page.keyboard.press("Enter")
page.wait_for_timeout(wait)
run("rm -f /tmp/ch.b64 /tmp/ch.sql; echo READY", 1200)
for i, ch in enumerate(chunks):
op = ">" if i == 0 else ">>"
run(f"printf '%s' '{ch}' {op} /tmp/ch.b64", 400)
run(
"base64 -d /tmp/ch.b64 > /tmp/ch.sql && "
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ch.sql; echo EXIT:$?",
12000,
)
run(
"psql -U postgres -d postgres -c "
"\"SELECT table_name FROM information_schema.tables "
"WHERE table_schema='public' AND table_name='change_history';\" ; echo TABLE_DONE",
4000,
)
text = page.evaluate(
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
)
browser.close()
ok = "EXIT:0" in text and "change_history" in text
print("SQL_OK", ok, flush=True)
if not ok:
print(text[-2500:], flush=True)
return 0 if ok else 6
if __name__ == "__main__":
raise SystemExit(main())

35
sql/change_history.sql Normal file
View File

@@ -0,0 +1,35 @@
-- Historial de cambios del Centro de Mando (agencia + cliente).
-- Idempotente.
create table if not exists public.change_history (
id uuid primary key default gen_random_uuid(),
tenant_id uuid references public.tenants(id) on delete set null,
tenant_name text,
user_id uuid,
user_email text not null default 'sistema',
user_role text,
entity_type text not null,
entity_id text,
action text not null,
summary text not null,
detail jsonb not null default '{}'::jsonb,
source text,
created_at timestamptz not null default now()
);
create index if not exists change_history_created_idx
on public.change_history (created_at desc);
create index if not exists change_history_tenant_created_idx
on public.change_history (tenant_id, created_at desc);
create index if not exists change_history_entity_idx
on public.change_history (entity_type, created_at desc);
comment on table public.change_history is
'Auditoría de cambios: usuario, fecha, acción y detalle (Zona de marca, creatividades, etc.)';
alter table public.change_history enable row level security;
-- Solo service role escribe/lee vía API (Centro de Mando). Sin policies abiertas.
drop policy if exists change_history_agency_read on public.change_history;

View File

@@ -12,6 +12,7 @@ import {
} from '@/lib/brandbook-enrich'; } from '@/lib/brandbook-enrich';
import { ensureCreativesDriveFolders } from '@/lib/creatives-drive'; import { ensureCreativesDriveFolders } from '@/lib/creatives-drive';
import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { logChange } from '@/lib/change-history';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 120; export const maxDuration = 120;
@@ -58,6 +59,29 @@ export async function POST(req: NextRequest) {
await ensureCreativesDriveFolders(r.tenantId, r.slug); 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({ return NextResponse.json({
ok: true, ok: true,
count: results.length, count: results.length,
@@ -103,5 +127,31 @@ export async function POST(req: NextRequest) {
drive = await ensureCreativesDriveFolders(result.tenantId, result.slug); 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 }); return NextResponse.json({ ok: result.ok, result, drive });
} }

View File

@@ -16,6 +16,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';
@@ -241,7 +242,8 @@ export async function PUT(req: NextRequest) {
book.brand_name = book.brand_name || label; book.brand_name = book.brand_name || label;
const admin = getAdmin(); const admin = getAdmin();
const { id } = await loadDoc(tenantId); const { id, book: previousBook } = await loadDoc(tenantId);
const created = !id;
let docId = id; let docId = id;
if (id) { if (id) {
@@ -277,6 +279,39 @@ export async function PUT(req: NextRequest) {
docId = insRow.id; 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% // Sincronizar simultáneamente con tenants.creative_style para consistencia 100%
const { data: tenantRow } = await admin const { data: tenantRow } = await admin
.from('tenants') .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
}
/>
);
}

View 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>
);
}

View File

@@ -24,6 +24,7 @@ import {
BookMarked, BookMarked,
Sparkles, Sparkles,
Images, Images,
History,
} from 'lucide-react'; } from 'lucide-react';
import TenantSwitcher from '@/components/TenantSwitcher'; import TenantSwitcher from '@/components/TenantSwitcher';
import type { DashboardModuleId } from '@/lib/tenant-modules'; 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"> <span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-500">
Agencia OS Agencia OS
</span> </span>
<div className="mt-1"> <div className="mt-1 space-y-0.5">
<NavLink <NavLink
item={{ item={{
href: '/dashboard/agencia', href: '/dashboard/agencia',
@@ -292,6 +293,15 @@ export default function DashboardShell({
}} }}
onNavigate={close} onNavigate={close}
/> />
<NavLink
item={{
href: '/dashboard/historial',
label: 'Historial de cambios',
icon: History,
iconClass: 'text-slate-500',
}}
onNavigate={close}
/>
</div> </div>
</div> </div>
) : null} ) : null}

171
src/lib/change-history.ts Normal file
View 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[] };
}