fix(historial): registrar todos los cambios de Zona de marca
Instrumenta kit, fuentes y estilo creativo; el listado filtra por rango e incluye hoy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
|||||||
type BrandAssetKind,
|
type BrandAssetKind,
|
||||||
type Brandbook,
|
type Brandbook,
|
||||||
} from '@/lib/brandbook';
|
} from '@/lib/brandbook';
|
||||||
|
import { logChange } from '@/lib/change-history';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
asset,
|
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
brandbook: book,
|
brandbook: book,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
detectSourceExt,
|
detectSourceExt,
|
||||||
extractSourceText,
|
extractSourceText,
|
||||||
} from '@/lib/extract-document-text';
|
} from '@/lib/extract-document-text';
|
||||||
|
import { logChange } from '@/lib/change-history';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const maxDuration = 60;
|
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({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
brandbook: next,
|
brandbook: next,
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ export async function GET(req: NextRequest) {
|
|||||||
const role = userRole(user);
|
const role = userRole(user);
|
||||||
const isAgency = isAgencyRole(role);
|
const isAgency = isAgencyRole(role);
|
||||||
const sp = req.nextUrl.searchParams;
|
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 offset = Number(sp.get('offset') || 0);
|
||||||
const entityType = sp.get('entity_type') || sp.get('entityType') || null;
|
const entityType = sp.get('entity_type') || sp.get('entityType') || null;
|
||||||
|
const since = sp.get('since') || null;
|
||||||
|
const until = sp.get('until') || null;
|
||||||
|
|
||||||
let tenantId =
|
let tenantId =
|
||||||
sp.get('tenant_id') || sp.get('tenantId') || null;
|
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
|
// Si portfolio wide, tenantId queda null → historial global
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, error } = await listChangeHistory({
|
const { rows, error, total } = await listChangeHistory({
|
||||||
tenantId,
|
tenantId,
|
||||||
entityType,
|
entityType,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
@@ -74,7 +78,10 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
rows,
|
rows,
|
||||||
|
total: total ?? rows.length,
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
portfolio_wide: isAgency && !tenantId,
|
portfolio_wide: isAgency && !tenantId,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,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';
|
||||||
|
|
||||||
@@ -206,7 +207,9 @@ export async function PUT(req: NextRequest) {
|
|||||||
const admin = getAdmin();
|
const admin = getAdmin();
|
||||||
const { data: existing, error: getErr } = await admin
|
const { data: existing, error: getErr } = await admin
|
||||||
.from('tenants')
|
.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)
|
.eq('id', tenantId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@@ -224,7 +227,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
const updatePayload: Record<string, any> = {
|
const updatePayload: Record<string, any> = {
|
||||||
creative_style: normalizedStyle,
|
creative_style: normalizedStyle,
|
||||||
settings: {
|
settings: {
|
||||||
...(existing.settings || {}),
|
...((existing.settings as Record<string, unknown>) || {}),
|
||||||
creative_style: normalizedStyle,
|
creative_style: normalizedStyle,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -273,7 +276,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
// Cargar brandbook de documents para compilar skill actualizado
|
// Cargar brandbook de documents para compilar skill actualizado
|
||||||
const { data: brandDoc } = await admin
|
const { data: brandDoc } = await admin
|
||||||
.from('documents')
|
.from('documents')
|
||||||
.select('content')
|
.select('id, content')
|
||||||
.eq('tenant_id', tenantId)
|
.eq('tenant_id', tenantId)
|
||||||
.eq('type', 'brandbook')
|
.eq('type', 'brandbook')
|
||||||
.order('updated_at', { ascending: false })
|
.order('updated_at', { ascending: false })
|
||||||
@@ -328,14 +331,14 @@ export async function PUT(req: NextRequest) {
|
|||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (brandDoc) {
|
if (brandDoc?.id) {
|
||||||
await admin
|
await admin
|
||||||
.from('documents')
|
.from('documents')
|
||||||
.update({
|
.update({
|
||||||
content: updatedBrandbook,
|
content: updatedBrandbook,
|
||||||
updated_at: updatedBrandbook.updated_at,
|
updated_at: updatedBrandbook.updated_at,
|
||||||
})
|
})
|
||||||
.eq('id', (brandDoc as any).id);
|
.eq('id', brandDoc.id);
|
||||||
} else {
|
} else {
|
||||||
await admin
|
await admin
|
||||||
.from('documents')
|
.from('documents')
|
||||||
@@ -349,6 +352,65 @@ export async function PUT(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeFlat: Record<string, unknown> = {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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(
|
const compiledMarkdown = compileClientSkillFromBrandbook(
|
||||||
updatedBrandbook,
|
updatedBrandbook,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
import { PageHeader, Surface, EmptyState } from '@/components/ui';
|
||||||
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
import { isoToday } from '@/lib/ad-tracker-range';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -157,7 +159,11 @@ export default function ChangeHistoryView({
|
|||||||
isAgency: boolean;
|
isAgency: boolean;
|
||||||
initialTenantId?: string | null;
|
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<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [needsMigration, setNeedsMigration] = useState(false);
|
const [needsMigration, setNeedsMigration] = useState(false);
|
||||||
@@ -172,27 +178,32 @@ export default function ChangeHistoryView({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
q.set('limit', '80');
|
q.set('limit', '200');
|
||||||
if (entityType) q.set('entity_type', entityType);
|
if (entityType) q.set('entity_type', entityType);
|
||||||
if (initialTenantId) q.set('tenant_id', initialTenantId);
|
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 r = await fetch(`/api/change-history?${q}`, { cache: 'no-store' });
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!r.ok || j.ok === false) {
|
if (!r.ok || j.ok === false) {
|
||||||
setNeedsMigration(Boolean(j.needsMigration));
|
setNeedsMigration(Boolean(j.needsMigration));
|
||||||
setError(j.error || 'No se pudo cargar el historial');
|
setError(j.error || 'No se pudo cargar el historial');
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setTotal(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNeedsMigration(false);
|
setNeedsMigration(false);
|
||||||
setRows(j.rows || []);
|
setRows(j.rows || []);
|
||||||
|
setTotal(typeof j.total === 'number' ? j.total : (j.rows || []).length);
|
||||||
setPortfolioWide(Boolean(j.portfolio_wide));
|
setPortfolioWide(Boolean(j.portfolio_wide));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Error de red');
|
setError(e instanceof Error ? e.message : 'Error de red');
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [entityType, initialTenantId]);
|
}, [entityType, initialTenantId, since, untilInclusive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -261,13 +272,15 @@ export default function ChangeHistoryView({
|
|||||||
<option value="calendar">Calendario</option>
|
<option value="calendar">Calendario</option>
|
||||||
<option value="tenant">Cliente</option>
|
<option value="tenant">Cliente</option>
|
||||||
</select>
|
</select>
|
||||||
{isAgency ? (
|
<p className="text-xs text-gray-500 ml-auto">
|
||||||
<p className="text-xs text-gray-500 ml-auto">
|
{rangeLabel ? `${rangeLabel} · ` : ''}
|
||||||
{portfolioWide
|
{total} cambio{total === 1 ? '' : 's'}
|
||||||
? 'Vista portafolio (todos los clientes)'
|
{isAgency
|
||||||
: 'Filtrado por el cliente del contexto'}
|
? portfolioWide
|
||||||
</p>
|
? ' · portafolio'
|
||||||
) : null}
|
: ' · cliente del contexto'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
{needsMigration ? (
|
{needsMigration ? (
|
||||||
|
|||||||
@@ -196,27 +196,40 @@ export async function logChange(input: ChangeHistoryInput): Promise<boolean> {
|
|||||||
export async function listChangeHistory(opts: {
|
export async function listChangeHistory(opts: {
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
entityType?: 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;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<{ rows: ChangeHistoryRow[]; error?: string }> {
|
}): Promise<{ rows: ChangeHistoryRow[]; error?: string; total?: number }> {
|
||||||
const admin = getAdmin();
|
const admin = getAdmin();
|
||||||
if (!admin) return { rows: [], error: 'Sin service role' };
|
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);
|
const offset = Math.max(opts.offset ?? 0, 0);
|
||||||
|
|
||||||
let q = admin
|
let q = admin
|
||||||
.from('change_history')
|
.from('change_history')
|
||||||
.select('*')
|
.select('*', { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(offset, offset + limit - 1);
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
if (opts.tenantId) q = q.eq('tenant_id', opts.tenantId);
|
if (opts.tenantId) q = q.eq('tenant_id', opts.tenantId);
|
||||||
if (opts.entityType) q = q.eq('entity_type', opts.entityType);
|
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) {
|
if (error) {
|
||||||
return { rows: [], error: error.message };
|
return { rows: [], error: error.message };
|
||||||
}
|
}
|
||||||
return { rows: (data || []) as ChangeHistoryRow[] };
|
return {
|
||||||
|
rows: (data || []) as ChangeHistoryRow[],
|
||||||
|
total: typeof count === 'number' ? count : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user