diff --git a/scripts/dokploy_apply_change_history.py b/scripts/dokploy_apply_change_history.py new file mode 100644 index 0000000..b7846db --- /dev/null +++ b/scripts/dokploy_apply_change_history.py @@ -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()) diff --git a/sql/change_history.sql b/sql/change_history.sql new file mode 100644 index 0000000..65aed96 --- /dev/null +++ b/sql/change_history.sql @@ -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; diff --git a/src/app/api/brandbook/enrich/route.ts b/src/app/api/brandbook/enrich/route.ts index 9f5e85d..835755b 100644 --- a/src/app/api/brandbook/enrich/route.ts +++ b/src/app/api/brandbook/enrich/route.ts @@ -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 }); } diff --git a/src/app/api/brandbook/route.ts b/src/app/api/brandbook/route.ts index 4f1880f..a143b93 100644 --- a/src/app/api/brandbook/route.ts +++ b/src/app/api/brandbook/route.ts @@ -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, + book as unknown as Record + ); + + 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') diff --git a/src/app/api/change-history/route.ts b/src/app/api/change-history/route.ts new file mode 100644 index 0000000..2a0fbfa --- /dev/null +++ b/src/app/api/change-history/route.ts @@ -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, + }); +} diff --git a/src/app/dashboard/historial/page.tsx b/src/app/dashboard/historial/page.tsx new file mode 100644 index 0000000..ab69380 --- /dev/null +++ b/src/app/dashboard/historial/page.tsx @@ -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 ( + + ); +} diff --git a/src/components/ChangeHistoryView.tsx b/src/components/ChangeHistoryView.tsx new file mode 100644 index 0000000..1fb0a3c --- /dev/null +++ b/src/components/ChangeHistoryView.tsx @@ -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; + source: string | null; + created_at: string; +}; + +const ENTITY_LABELS: Record = { + 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 }) { + 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 ( +
    + {changes.map((c, i) => ( +
  • + {c.field} +
    + + {c.from == null || c.from === '' + ? '∅' + : typeof c.from === 'string' + ? c.from + : JSON.stringify(c.from)} + + + {c.to == null || c.to === '' + ? '∅' + : typeof c.to === 'string' + ? c.to + : JSON.stringify(c.to)} + +
    +
  • + ))} +
+ ); + } + + if (fields?.length || sources?.length) { + return ( +
+ {sources?.length ? ( +

+ Fuentes:{' '} + {sources.join(', ')} +

+ ) : null} + {fields?.length ? ( +

+ Campos:{' '} + {fields.join(', ')} +

+ ) : null} + {typeof detail.note === 'string' ? ( +

{detail.note}

+ ) : null} +
+ ); + } + + const keys = Object.keys(detail || {}); + if (!keys.length) { + return

Sin detalle adicional.

; + } + return ( +
+      {JSON.stringify(detail, null, 2)}
+    
+ ); +} + +export default function ChangeHistoryView({ + isAgency, + initialTenantId, +}: { + isAgency: boolean; + initialTenantId?: string | null; +}) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [needsMigration, setNeedsMigration] = useState(false); + const [entityType, setEntityType] = useState(''); + const [portfolioWide, setPortfolioWide] = useState(false); + const [openId, setOpenId] = useState(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 ( +
+ 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" + > + + Actualizar + + } + /> + + + + + + {isAgency ? ( +

+ {portfolioWide + ? 'Vista portafolio (todos los clientes)' + : 'Filtrado por el cliente del contexto'} +

+ ) : null} +
+ + {needsMigration ? ( + +

+ Falta aplicar la migración SQL{' '} + + sql/change_history.sql + {' '} + en Supabase. Hasta entonces no se pueden guardar ni listar cambios. +

+
+ ) : null} + + {error && !needsMigration ? ( + +

{error}

+
+ ) : null} + + + {loading && !rows.length ? ( +
+ + Cargando historial… +
+ ) : !rows.length ? ( +
+ +
+ ) : ( +
    + {rows.map((row) => { + const open = openId === row.id; + return ( +
  • + + {open ? ( +
    + + {row.source ? ( +

    + Origen: {row.source} +

    + ) : null} +
    + ) : null} +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 12d284b..3ff9823 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -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({ Agencia OS -
+
+
) : null} diff --git a/src/lib/change-history.ts b/src/lib/change-history.ts new file mode 100644 index 0000000..6845ad7 --- /dev/null +++ b/src/lib/change-history.ts @@ -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 | 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; + 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 | null | undefined, + after: Record | 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 { + 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[] }; +}