feat: LinkedIn editorial, accesos agencia estables y deshacer en historial
Integra estrategias LinkedIn en calendarios (backlog publicado vs pendiente), unifica roles/cookies de vista agencia y permite deshacer cambios de brandbook dejando constancia en el historial. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
401
scripts/audit_all_brands.ts
Normal file
401
scripts/audit_all_brands.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Full portfolio audit: Drive, brandbook, Meta/IG/FB/GAds, creatives folders.
|
||||
* Optionally --fix to rematch Drive, enrich, ensure Ads/RRSS folders.
|
||||
*
|
||||
* npx tsx scripts/audit_all_brands.ts
|
||||
* npx tsx scripts/audit_all_brands.ts --fix
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
|
||||
import { ensureCreativesDriveFolders } from '../src/lib/creatives-drive';
|
||||
import {
|
||||
applyMatches,
|
||||
listClientFoldersUnderClientes,
|
||||
matchFoldersToTenants,
|
||||
scoreFolderToTenant,
|
||||
} from '../src/lib/google-drive';
|
||||
import { resolveBrandSlug } from '../src/lib/tenant-brand-aliases';
|
||||
|
||||
const FIX = process.argv.includes('--fix');
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const h = {
|
||||
apikey: key,
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
/** Meta IG user ids / page ids are numeric; reject profile URLs. */
|
||||
function isMetaNumericId(v: string | null | undefined): boolean {
|
||||
return Boolean(v && /^\d{5,}$/.test(v.trim()));
|
||||
}
|
||||
|
||||
/** Ad account: act_123 or bare digits (normalize to act_ on fix). */
|
||||
function isMetaAdAccountId(v: string | null | undefined): boolean {
|
||||
if (!v) return false;
|
||||
const s = v.trim();
|
||||
return /^act_\d+$/i.test(s) || /^\d{5,}$/.test(s);
|
||||
}
|
||||
|
||||
function normalizeAdAccountId(v: string): string {
|
||||
const s = v.trim();
|
||||
if (/^act_/i.test(s)) return s;
|
||||
if (/^\d+$/.test(s)) return `act_${s}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
type Tenant = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
agency_followed?: boolean;
|
||||
drive_folder_id?: string | null;
|
||||
meta_ad_account_id?: string | null;
|
||||
meta_page_id?: string | null;
|
||||
meta_ig_user_id?: string | null;
|
||||
default_landing_url?: string | null;
|
||||
creatives_enabled?: boolean | null;
|
||||
};
|
||||
|
||||
type Integ = {
|
||||
id?: string;
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
status: string;
|
||||
external_account_id?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type Row = {
|
||||
slug: string;
|
||||
name: string;
|
||||
followed: boolean;
|
||||
brandbook: boolean;
|
||||
driveTenant: boolean;
|
||||
driveInteg: boolean;
|
||||
driveLabel: string;
|
||||
metaAds: boolean;
|
||||
facebook: boolean;
|
||||
instagram: boolean;
|
||||
googleAds: boolean;
|
||||
ga4: boolean;
|
||||
issues: string[];
|
||||
scoreHint: number;
|
||||
};
|
||||
|
||||
async function rest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(`${url}/rest/v1/${path}`, {
|
||||
...init,
|
||||
headers: { ...h, ...(init?.headers || {}) },
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) throw new Error(`${path} ${r.status}: ${text.slice(0, 300)}`);
|
||||
return text ? (JSON.parse(text) as T) : ([] as T);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenants = await rest<Tenant[]>(
|
||||
'tenants?select=id,slug,name,agency_followed,drive_folder_id,meta_ad_account_id,meta_page_id,meta_ig_user_id,default_landing_url,creatives_enabled&order=name.asc'
|
||||
);
|
||||
const active = tenants.filter((t) => t.agency_followed !== false);
|
||||
const archived = tenants.filter((t) => t.agency_followed === false);
|
||||
|
||||
const integs = await rest<Integ[]>(
|
||||
'integrations?select=id,tenant_id,provider,status,external_account_id,label'
|
||||
);
|
||||
const byTenant = new Map<string, Integ[]>();
|
||||
for (const i of integs) {
|
||||
const arr = byTenant.get(i.tenant_id) || [];
|
||||
arr.push(i);
|
||||
byTenant.set(i.tenant_id, arr);
|
||||
}
|
||||
|
||||
const docs = await rest<{ tenant_id: string; type: string }[]>(
|
||||
'documents?type=eq.brandbook&select=tenant_id,type'
|
||||
);
|
||||
const hasBook = new Set(docs.map((d) => d.tenant_id));
|
||||
|
||||
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
|
||||
console.log(
|
||||
`Clientes root=${clientesRoot?.name || '?'} folders=${folders.length} active=${active.length} archived=${archived.length} fix=${FIX}`
|
||||
);
|
||||
|
||||
const rows: Row[] = [];
|
||||
|
||||
for (const t of active) {
|
||||
const list = byTenant.get(t.id) || [];
|
||||
const connected = list.filter((i) => i.status === 'connected');
|
||||
const find = (p: string) =>
|
||||
connected.find((i) => i.provider === p) ||
|
||||
list.find((i) => i.provider === p);
|
||||
|
||||
const drive = find('google_drive');
|
||||
const meta = find('meta_ads');
|
||||
const fb = find('facebook');
|
||||
const ig = find('instagram');
|
||||
const gads = find('google_ads');
|
||||
const ga4 = find('ga4');
|
||||
|
||||
const issues: string[] = [];
|
||||
if (!hasBook.has(t.id)) issues.push('sin brandbook');
|
||||
if (!t.drive_folder_id && drive?.status !== 'connected') {
|
||||
issues.push('sin Drive');
|
||||
}
|
||||
if (
|
||||
drive?.status === 'connected' &&
|
||||
/navidad|black\s*friday|campa/i.test(drive.label || '')
|
||||
) {
|
||||
issues.push(`Drive sospechoso: ${drive.label}`);
|
||||
}
|
||||
if (t.drive_folder_id && drive?.status === 'connected') {
|
||||
if (drive.external_account_id !== t.drive_folder_id) {
|
||||
issues.push('drive_folder_id ≠ integration');
|
||||
}
|
||||
}
|
||||
if (t.drive_folder_id && drive?.status !== 'connected') {
|
||||
issues.push('drive_folder_id sin integration connected');
|
||||
}
|
||||
|
||||
// Sync / validate tenant meta ids from integrations
|
||||
if (!t.meta_ad_account_id && meta?.status === 'connected' && meta.external_account_id) {
|
||||
issues.push('meta_ad_account_id vacío (hay integration)');
|
||||
}
|
||||
if (t.meta_ad_account_id && !isMetaAdAccountId(t.meta_ad_account_id)) {
|
||||
issues.push(`meta_ad_account_id inválido: ${t.meta_ad_account_id}`);
|
||||
} else if (
|
||||
t.meta_ad_account_id &&
|
||||
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
|
||||
t.meta_page_id === t.meta_ad_account_id
|
||||
) {
|
||||
issues.push('meta_ad_account_id = meta_page_id (falta act_)');
|
||||
}
|
||||
if (!t.meta_page_id && fb?.status === 'connected' && fb.external_account_id) {
|
||||
issues.push('meta_page_id vacío');
|
||||
}
|
||||
if (t.meta_page_id && !isMetaNumericId(t.meta_page_id)) {
|
||||
issues.push(`meta_page_id inválido: ${t.meta_page_id}`);
|
||||
}
|
||||
if (!t.meta_ig_user_id && ig?.status === 'connected' && ig.external_account_id) {
|
||||
issues.push('meta_ig_user_id vacío');
|
||||
}
|
||||
if (
|
||||
t.meta_ig_user_id &&
|
||||
!isMetaNumericId(t.meta_ig_user_id)
|
||||
) {
|
||||
issues.push(`meta_ig_user_id no numérico: ${t.meta_ig_user_id}`);
|
||||
}
|
||||
if (
|
||||
ig?.status === 'connected' &&
|
||||
ig.external_account_id &&
|
||||
!isMetaNumericId(ig.external_account_id)
|
||||
) {
|
||||
issues.push(`IG integration no numérica: ${ig.external_account_id}`);
|
||||
}
|
||||
|
||||
// Best Drive folder candidate if missing
|
||||
let scoreHint = 0;
|
||||
if (!t.drive_folder_id || drive?.status !== 'connected') {
|
||||
const scored = folders
|
||||
.map((f) => ({
|
||||
f,
|
||||
score: scoreFolderToTenant(f.name, t.name, t.slug),
|
||||
}))
|
||||
.filter((x) => x.score >= 70)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
scoreHint = scored[0]?.score || 0;
|
||||
if (scored[0]) {
|
||||
issues.push(`Drive candidate: ${scored[0].f.name} (${scored[0].score})`);
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
followed: true,
|
||||
brandbook: hasBook.has(t.id),
|
||||
driveTenant: Boolean(t.drive_folder_id),
|
||||
driveInteg: drive?.status === 'connected',
|
||||
driveLabel: drive?.label || '',
|
||||
metaAds: meta?.status === 'connected',
|
||||
facebook: fb?.status === 'connected',
|
||||
instagram: ig?.status === 'connected',
|
||||
googleAds: gads?.status === 'connected',
|
||||
ga4: ga4?.status === 'connected',
|
||||
issues,
|
||||
scoreHint,
|
||||
});
|
||||
}
|
||||
|
||||
// Print table
|
||||
console.log('\n=== ACTIVE PORTFOLIO ===');
|
||||
for (const r of rows) {
|
||||
const flags = [
|
||||
r.brandbook ? 'BB' : '--',
|
||||
r.driveInteg ? 'DRV' : '--',
|
||||
r.metaAds ? 'META' : '----',
|
||||
r.instagram ? 'IG' : '--',
|
||||
r.facebook ? 'FB' : '--',
|
||||
r.googleAds ? 'GADS' : '----',
|
||||
r.ga4 ? 'GA4' : '---',
|
||||
].join(' ');
|
||||
const issueStr = r.issues.length ? ` ⚠ ${r.issues.join('; ')}` : '';
|
||||
console.log(
|
||||
`${r.slug.padEnd(22)} ${flags} ${r.driveLabel.slice(0, 36).padEnd(36)}${issueStr}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n=== ARCHIVED ===');
|
||||
for (const t of archived) {
|
||||
console.log(` ${t.slug} · ${t.name}`);
|
||||
}
|
||||
|
||||
if (FIX) {
|
||||
console.log('\n=== FIXING ===');
|
||||
// Rematch Drive for those missing / suspicious
|
||||
const needDrive = active.filter((t) => {
|
||||
const list = byTenant.get(t.id) || [];
|
||||
const drive = list.find((i) => i.provider === 'google_drive');
|
||||
if (drive?.status === 'connected' && /navidad/i.test(drive.label || '')) {
|
||||
return true;
|
||||
}
|
||||
return !t.drive_folder_id || drive?.status !== 'connected';
|
||||
});
|
||||
|
||||
if (needDrive.length && folders.length) {
|
||||
const matches = await matchFoldersToTenants(
|
||||
needDrive.map((t) => ({ id: t.id, name: t.name, slug: t.slug })),
|
||||
folders
|
||||
);
|
||||
const good = matches.filter(
|
||||
(m) =>
|
||||
m.status === 'matched' &&
|
||||
m.folderId &&
|
||||
!/navidad/i.test(m.folderName || '')
|
||||
);
|
||||
if (good.length) {
|
||||
await applyMatches(good);
|
||||
for (const m of good) {
|
||||
await rest(`tenants?id=eq.${m.tenantId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({ drive_folder_id: m.folderId }),
|
||||
});
|
||||
console.log(`[drive] ${m.tenantSlug} ← ${m.folderName}`);
|
||||
}
|
||||
} else {
|
||||
console.log('[drive] no new high-confidence matches');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync meta_* columns from connected integrations (numeric IDs only)
|
||||
for (const t of active) {
|
||||
const list = byTenant.get(t.id) || [];
|
||||
const connected = list.filter((i) => i.status === 'connected');
|
||||
const patch: Record<string, string> = {};
|
||||
const meta = connected.find((i) => i.provider === 'meta_ads');
|
||||
const fb = connected.find((i) => i.provider === 'facebook');
|
||||
const ig = connected.find((i) => i.provider === 'instagram');
|
||||
if (meta?.external_account_id && isMetaAdAccountId(meta.external_account_id)) {
|
||||
const ad = normalizeAdAccountId(meta.external_account_id);
|
||||
if (t.meta_ad_account_id !== ad) patch.meta_ad_account_id = ad;
|
||||
if (meta.id && meta.external_account_id !== ad) {
|
||||
await rest(`integrations?id=eq.${meta.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({ external_account_id: ad }),
|
||||
});
|
||||
}
|
||||
} else if (!t.meta_ad_account_id && meta?.external_account_id) {
|
||||
console.warn(`[ids] skip bad meta_ads for ${t.slug}: ${meta.external_account_id}`);
|
||||
}
|
||||
if (
|
||||
(!t.meta_page_id || !isMetaNumericId(t.meta_page_id)) &&
|
||||
fb?.external_account_id &&
|
||||
isMetaNumericId(fb.external_account_id)
|
||||
) {
|
||||
patch.meta_page_id = fb.external_account_id.trim();
|
||||
}
|
||||
if (
|
||||
(!t.meta_ig_user_id || !isMetaNumericId(t.meta_ig_user_id)) &&
|
||||
ig?.external_account_id &&
|
||||
isMetaNumericId(ig.external_account_id)
|
||||
) {
|
||||
patch.meta_ig_user_id = ig.external_account_id.trim();
|
||||
} else if (
|
||||
ig?.external_account_id &&
|
||||
!isMetaNumericId(ig.external_account_id)
|
||||
) {
|
||||
console.warn(`[ids] skip bad IG for ${t.slug}: ${ig.external_account_id}`);
|
||||
}
|
||||
// Normalize bare digit ad account already on tenant
|
||||
if (
|
||||
t.meta_ad_account_id &&
|
||||
/^\d+$/.test(t.meta_ad_account_id.trim()) &&
|
||||
!patch.meta_ad_account_id
|
||||
) {
|
||||
patch.meta_ad_account_id = normalizeAdAccountId(t.meta_ad_account_id);
|
||||
}
|
||||
const drive = connected.find((i) => i.provider === 'google_drive');
|
||||
if (
|
||||
drive?.external_account_id &&
|
||||
!/navidad/i.test(drive.label || '') &&
|
||||
t.drive_folder_id !== drive.external_account_id
|
||||
) {
|
||||
patch.drive_folder_id = drive.external_account_id;
|
||||
}
|
||||
if (Object.keys(patch).length) {
|
||||
await rest(`tenants?id=eq.${t.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
console.log(`[ids] ${t.slug}`, patch);
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich + creatives folders for all active
|
||||
for (const t of active) {
|
||||
const r = await enrichTenantBrandbook({
|
||||
tenantId: t.id,
|
||||
forceSeed: !hasBook.has(t.id),
|
||||
});
|
||||
const drive = await ensureCreativesDriveFolders(
|
||||
t.id,
|
||||
resolveBrandSlug(t.slug) || t.slug
|
||||
);
|
||||
console.log(
|
||||
`[enrich] ${t.slug} bb=${r.ok} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning || 'skip'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary JSON for canvas
|
||||
const summary = {
|
||||
active: active.length,
|
||||
archived: archived.length,
|
||||
withBrandbook: rows.filter((r) => r.brandbook).length,
|
||||
withDrive: rows.filter((r) => r.driveInteg).length,
|
||||
withMeta: rows.filter((r) => r.metaAds).length,
|
||||
withIg: rows.filter((r) => r.instagram).length,
|
||||
withIssues: rows.filter((r) => r.issues.length).length,
|
||||
rows,
|
||||
archivedSlugs: archived.map((t) => t.slug),
|
||||
};
|
||||
const outPath = resolve(process.cwd(), 'scripts/.audit_portfolio.json');
|
||||
await import('fs/promises').then((fs) =>
|
||||
fs.writeFile(outPath, JSON.stringify(summary, null, 2), 'utf8')
|
||||
);
|
||||
console.log(`\nWrote ${outPath}`);
|
||||
console.log(
|
||||
`Summary: ${summary.withBrandbook}/${summary.active} BB · ${summary.withDrive}/${summary.active} Drive · ${summary.withMeta} Meta · ${summary.withIssues} with issues`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user