diff --git a/src/app/api/brandbook/kit/route.ts b/src/app/api/brandbook/kit/route.ts index fd25b3d..ea026f3 100644 --- a/src/app/api/brandbook/kit/route.ts +++ b/src/app/api/brandbook/kit/route.ts @@ -31,6 +31,7 @@ import { type BrandAssetKind, type Brandbook, } from '@/lib/brandbook'; +import { logChange } from '@/lib/change-history'; import { createHash, randomUUID } from 'crypto'; export const dynamic = 'force-dynamic'; @@ -254,6 +255,37 @@ export async function POST(req: NextRequest) { } } + await logChange({ + tenantId: ctx.tenantId, + tenantName: ctx.label, + actor: { + userId: user.id, + email: user.email, + role: userRole(user), + }, + entityType: 'brandbook', + entityId: docId, + action: 'kit_upload', + summary: `Kit · subida ${kind}${role ? ` (${role})` : ''} · ${label} · ${ctx.label}`, + detail: { + kind, + role, + label, + file_name: safeName, + asset_id: asset.id, + asset_file_id: id, + changes: [ + { + field: 'kit_assets', + from: null, + to: `${kind}: ${label}`, + }, + ], + revertable: false, + }, + source: 'api/brandbook/kit', + }); + return NextResponse.json({ ok: true, asset, @@ -311,6 +343,38 @@ export async function DELETE(req: NextRequest) { ); } + await logChange({ + tenantId: ctx.tenantId, + tenantName: ctx.label, + actor: { + userId: user.id, + email: user.email, + role: userRole(user), + }, + entityType: 'brandbook', + entityId: docId, + action: 'kit_delete', + summary: `Kit · eliminado ${removed?.kind || 'asset'}${ + removed?.label ? ` (${removed.label})` : '' + } · ${ctx.label}`, + detail: { + removed, + kit_id: kitId || null, + asset_file_id: fileId || null, + changes: removed + ? [ + { + field: 'kit_assets', + from: `${removed.kind}: ${removed.label}`, + to: null, + }, + ] + : [], + revertable: false, + }, + source: 'api/brandbook/kit', + }); + return NextResponse.json({ ok: true, brandbook: book, diff --git a/src/app/api/brandbook/upload/route.ts b/src/app/api/brandbook/upload/route.ts index 98168dd..841b8db 100644 --- a/src/app/api/brandbook/upload/route.ts +++ b/src/app/api/brandbook/upload/route.ts @@ -19,6 +19,7 @@ import { detectSourceExt, extractSourceText, } from '@/lib/extract-document-text'; +import { logChange } from '@/lib/change-history'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; @@ -246,6 +247,33 @@ export async function POST(req: NextRequest) { } } + await logChange({ + tenantId: ctx.tenantId, + tenantName: ctx.label, + actor: { + userId: user.id, + email: user.email, + role: userRole(user), + }, + entityType: 'brandbook', + entityId: id, + action: 'source_upload', + summary: `Fuente · ${filename} · ${ctx.label}`, + detail: { + filename, + chars: text.length, + changes: [ + { + field: 'source_excerpts', + from: null, + to: filename, + }, + ], + revertable: false, + }, + source: 'api/brandbook/upload', + }); + return NextResponse.json({ ok: true, brandbook: next, diff --git a/src/app/api/change-history/route.ts b/src/app/api/change-history/route.ts index 2a0fbfa..61eb9b8 100644 --- a/src/app/api/change-history/route.ts +++ b/src/app/api/change-history/route.ts @@ -22,9 +22,11 @@ export async function GET(req: NextRequest) { const role = userRole(user); const isAgency = isAgencyRole(role); const sp = req.nextUrl.searchParams; - const limit = Number(sp.get('limit') || 50); + const limit = Number(sp.get('limit') || 100); const offset = Number(sp.get('offset') || 0); const entityType = sp.get('entity_type') || sp.get('entityType') || null; + const since = sp.get('since') || null; + const until = sp.get('until') || null; let tenantId = sp.get('tenant_id') || sp.get('tenantId') || null; @@ -46,9 +48,11 @@ export async function GET(req: NextRequest) { // Si portfolio wide, tenantId queda null → historial global } - const { rows, error } = await listChangeHistory({ + const { rows, error, total } = await listChangeHistory({ tenantId, entityType, + since, + until, limit, offset, }); @@ -74,7 +78,10 @@ export async function GET(req: NextRequest) { return NextResponse.json({ ok: true, rows, + total: total ?? rows.length, tenant_id: tenantId, portfolio_wide: isAgency && !tenantId, + since, + until, }); } diff --git a/src/app/api/creatives/style/route.ts b/src/app/api/creatives/style/route.ts index 6560be4..1d1cab1 100644 --- a/src/app/api/creatives/style/route.ts +++ b/src/app/api/creatives/style/route.ts @@ -14,6 +14,7 @@ import { seedBrandbook, type Brandbook, } from '@/lib/brandbook'; +import { diffFields, logChange } from '@/lib/change-history'; export const dynamic = 'force-dynamic'; @@ -206,7 +207,9 @@ export async function PUT(req: NextRequest) { const admin = getAdmin(); const { data: existing, error: getErr } = await admin .from('tenants') - .select('id, name, slug, settings') + .select( + 'id, name, slug, settings, creative_style, creatives_enabled, meta_ad_account_id, meta_page_id, meta_ig_user_id, slack_channel_id, drive_folder_id, default_landing_url, canva_templates, campaign_audience, campaign_products' + ) .eq('id', tenantId) .maybeSingle(); @@ -224,7 +227,7 @@ export async function PUT(req: NextRequest) { const updatePayload: Record = { creative_style: normalizedStyle, settings: { - ...(existing.settings || {}), + ...((existing.settings as Record) || {}), creative_style: normalizedStyle, }, }; @@ -273,7 +276,7 @@ export async function PUT(req: NextRequest) { // Cargar brandbook de documents para compilar skill actualizado const { data: brandDoc } = await admin .from('documents') - .select('content') + .select('id, content') .eq('tenant_id', tenantId) .eq('type', 'brandbook') .order('updated_at', { ascending: false }) @@ -328,14 +331,14 @@ export async function PUT(req: NextRequest) { updated_at: new Date().toISOString(), }; - if (brandDoc) { + if (brandDoc?.id) { await admin .from('documents') .update({ content: updatedBrandbook, updated_at: updatedBrandbook.updated_at, }) - .eq('id', (brandDoc as any).id); + .eq('id', brandDoc.id); } else { await admin .from('documents') @@ -349,6 +352,65 @@ export async function PUT(req: NextRequest) { }); } + const beforeFlat: Record = { + creatives_enabled: existing.creatives_enabled, + meta_ad_account_id: existing.meta_ad_account_id, + meta_page_id: existing.meta_page_id, + meta_ig_user_id: existing.meta_ig_user_id, + slack_channel_id: existing.slack_channel_id, + drive_folder_id: existing.drive_folder_id, + default_landing_url: existing.default_landing_url, + canva_templates: existing.canva_templates, + campaign_audience: existing.campaign_audience, + campaign_products: existing.campaign_products, + creative_style: existing.creative_style, + }; + const afterFlat: Record = { + creatives_enabled: + updatePayload.creatives_enabled ?? existing.creatives_enabled, + meta_ad_account_id: + updatePayload.meta_ad_account_id ?? existing.meta_ad_account_id, + meta_page_id: updatePayload.meta_page_id ?? existing.meta_page_id, + meta_ig_user_id: + updatePayload.meta_ig_user_id ?? existing.meta_ig_user_id, + slack_channel_id: + updatePayload.slack_channel_id ?? existing.slack_channel_id, + drive_folder_id: + updatePayload.drive_folder_id ?? existing.drive_folder_id, + default_landing_url: + updatePayload.default_landing_url ?? existing.default_landing_url, + canva_templates: + updatePayload.canva_templates ?? existing.canva_templates, + campaign_audience: + updatePayload.campaign_audience ?? existing.campaign_audience, + campaign_products: + updatePayload.campaign_products ?? existing.campaign_products, + creative_style: normalizedStyle, + }; + const styleChanges = diffFields(beforeFlat, afterFlat, { maxFields: 30 }); + + await logChange({ + tenantId, + tenantName: existing.name, + actor: { + userId: user.id, + email: user.email, + role, + }, + entityType: 'brandbook', + entityId: brandDoc?.id || tenantId, + action: 'creative_style_update', + summary: styleChanges.length + ? `Estilo creativo · ${styleChanges.length} campo(s) · ${existing.name}` + : `Estilo creativo guardado · ${existing.name}`, + detail: { + changes: styleChanges, + revertable: false, + synced_brandbook: true, + }, + source: 'api/creatives/style', + }); + const compiledMarkdown = compileClientSkillFromBrandbook( updatedBrandbook, { diff --git a/src/components/ChangeHistoryView.tsx b/src/components/ChangeHistoryView.tsx index 6d39d35..780722e 100644 --- a/src/components/ChangeHistoryView.tsx +++ b/src/components/ChangeHistoryView.tsx @@ -14,6 +14,8 @@ import { Loader2, } from 'lucide-react'; import { PageHeader, Surface, EmptyState } from '@/components/ui'; +import { useDateRange } from '@/components/DateRangeContext'; +import { isoToday } from '@/lib/ad-tracker-range'; type Row = { id: string; @@ -157,7 +159,11 @@ export default function ChangeHistoryView({ isAgency: boolean; initialTenantId?: string | null; }) { + const { since, label: rangeLabel } = useDateRange(); + // El date range global de Ads cierra en «ayer»; el historial debe incluir hoy. + const untilInclusive = isoToday(); const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [needsMigration, setNeedsMigration] = useState(false); @@ -172,27 +178,32 @@ export default function ChangeHistoryView({ setError(null); try { const q = new URLSearchParams(); - q.set('limit', '80'); + q.set('limit', '200'); if (entityType) q.set('entity_type', entityType); if (initialTenantId) q.set('tenant_id', initialTenantId); + if (since) q.set('since', since); + q.set('until', untilInclusive); 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([]); + setTotal(0); return; } setNeedsMigration(false); setRows(j.rows || []); + setTotal(typeof j.total === 'number' ? j.total : (j.rows || []).length); setPortfolioWide(Boolean(j.portfolio_wide)); } catch (e) { setError(e instanceof Error ? e.message : 'Error de red'); setRows([]); + setTotal(0); } finally { setLoading(false); } - }, [entityType, initialTenantId]); + }, [entityType, initialTenantId, since, untilInclusive]); useEffect(() => { void load(); @@ -261,13 +272,15 @@ export default function ChangeHistoryView({ - {isAgency ? ( -

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

- ) : null} +

+ {rangeLabel ? `${rangeLabel} · ` : ''} + {total} cambio{total === 1 ? '' : 's'} + {isAgency + ? portfolioWide + ? ' · portafolio' + : ' · cliente del contexto' + : ''} +

{needsMigration ? ( diff --git a/src/lib/change-history.ts b/src/lib/change-history.ts index 07f7074..093c7ef 100644 --- a/src/lib/change-history.ts +++ b/src/lib/change-history.ts @@ -196,27 +196,40 @@ export async function logChange(input: ChangeHistoryInput): Promise { export async function listChangeHistory(opts: { tenantId?: string | null; entityType?: string | null; + /** Inclusive YYYY-MM-DD (UTC day start) */ + since?: string | null; + /** Inclusive YYYY-MM-DD (UTC day end) */ + until?: string | null; limit?: number; offset?: number; -}): Promise<{ rows: ChangeHistoryRow[]; error?: string }> { +}): Promise<{ rows: ChangeHistoryRow[]; error?: string; total?: number }> { const admin = getAdmin(); if (!admin) return { rows: [], error: 'Sin service role' }; - const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500); const offset = Math.max(opts.offset ?? 0, 0); let q = admin .from('change_history') - .select('*') + .select('*', { count: 'exact' }) .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); + if (opts.since && /^\d{4}-\d{2}-\d{2}$/.test(opts.since)) { + q = q.gte('created_at', `${opts.since}T00:00:00.000Z`); + } + if (opts.until && /^\d{4}-\d{2}-\d{2}$/.test(opts.until)) { + q = q.lte('created_at', `${opts.until}T23:59:59.999Z`); + } - const { data, error } = await q; + const { data, error, count } = await q; if (error) { return { rows: [], error: error.message }; } - return { rows: (data || []) as ChangeHistoryRow[] }; + return { + rows: (data || []) as ChangeHistoryRow[], + total: typeof count === 'number' ? count : undefined, + }; }