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:
232
scripts/fix_brand_gaps.ts
Normal file
232
scripts/fix_brand_gaps.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Fix remaining gaps for TYD / TSP / ECP / DTATF:
|
||||
* - Rematch Drive folders (esp. ECP, TSP)
|
||||
* - Ensure brandbooks exist
|
||||
* - Seed disconnected integration shells for new tenants
|
||||
* - Ensure Creatividades-Ads/RRSS folders
|
||||
*
|
||||
* npx tsx scripts/fix_brand_gaps.ts
|
||||
*/
|
||||
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 {
|
||||
BRAND_DISPLAY_NAMES,
|
||||
brandSiblingNote,
|
||||
} from '../src/lib/tenant-brand-aliases';
|
||||
|
||||
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',
|
||||
Prefer: 'return=representation',
|
||||
};
|
||||
|
||||
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
|
||||
|
||||
const PROVIDERS = [
|
||||
'meta_ads',
|
||||
'facebook',
|
||||
'instagram',
|
||||
'google_ads',
|
||||
'google_drive',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'gohighlevel',
|
||||
'clientify',
|
||||
'linkedin',
|
||||
] as const;
|
||||
|
||||
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, 400)}`);
|
||||
return text ? (JSON.parse(text) as T) : ([] as T);
|
||||
}
|
||||
|
||||
async function ensureIntegrationShells(tenantId: string) {
|
||||
const existing = await rest<{ provider: string }[]>(
|
||||
`integrations?tenant_id=eq.${tenantId}&select=provider`
|
||||
);
|
||||
const have = new Set(existing.map((r) => r.provider));
|
||||
for (const provider of PROVIDERS) {
|
||||
if (have.has(provider)) continue;
|
||||
await rest('integrations', {
|
||||
method: 'POST',
|
||||
headers: { Prefer: 'resolution=ignore-duplicates,return=minimal' },
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
enabled: false,
|
||||
label: provider,
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenants = await rest<
|
||||
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
|
||||
>(
|
||||
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
|
||||
);
|
||||
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
|
||||
console.log(
|
||||
'focus tenants',
|
||||
tenants.map((t) => t.slug).join(', ')
|
||||
);
|
||||
|
||||
for (const t of tenants) {
|
||||
await ensureIntegrationShells(t.id);
|
||||
console.log(`[shells] ${t.slug}`);
|
||||
}
|
||||
|
||||
// Drive rematch under Clientes
|
||||
try {
|
||||
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
|
||||
console.log(
|
||||
`Drive root=${clientesRoot?.name || '?'} folders=${folders.length}`
|
||||
);
|
||||
|
||||
// Manual preferred matches by name heuristics
|
||||
const prefer: Record<string, RegExp> = {
|
||||
ecp: /^(?:\d+[\.\-\s_]*)?(?:ecp|escuela\s*de\s*cuidadores|cuidadores)/i,
|
||||
tsp: /^(?:\d+[\.\-\s_]*)?(?:tsp|talento\s*scholar|scholar)/i,
|
||||
tyd: /^(?:\d+[\.\-\s_]*)?(?:06[\.\-\s_]*)?talento\s*y\s*deporte/i,
|
||||
dtatf: /^(?:\d+[\.\-\s_]*)?(?:08[\.\-\s_]*)?(?:detuatu|de\s*tu\s*a\s*tu)/i,
|
||||
};
|
||||
|
||||
for (const slug of FOCUS) {
|
||||
const t = bySlug[slug];
|
||||
if (!t) continue;
|
||||
// skip Navidad-like noise
|
||||
const candidates = folders
|
||||
.map((f) => ({
|
||||
f,
|
||||
score: prefer[slug]?.test(f.name)
|
||||
? 100
|
||||
: scoreFolderToTenant(f.name, t.name, t.slug),
|
||||
}))
|
||||
.filter((x) => x.score >= 70 && !/navidad/i.test(x.f.name))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
console.log(
|
||||
`[drive candidates ${slug}]`,
|
||||
candidates.slice(0, 5).map((c) => `${c.score}:${c.f.name}`)
|
||||
);
|
||||
|
||||
const best = candidates[0];
|
||||
if (!best) continue;
|
||||
|
||||
await rest(
|
||||
`integrations?tenant_id=eq.${t.id}&provider=eq.google_drive`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({
|
||||
status: 'connected',
|
||||
enabled: true,
|
||||
external_account_id: best.f.id,
|
||||
label: best.f.name,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
await rest(`tenants?id=eq.${t.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({ drive_folder_id: best.f.id }),
|
||||
});
|
||||
console.log(`[drive] ${slug} ← ${best.f.name} (${best.f.id})`);
|
||||
}
|
||||
|
||||
// Also run generic matcher for any still missing
|
||||
const still = await rest<
|
||||
{ id: string; slug: string; name: string; drive_folder_id?: string | null }[]
|
||||
>(
|
||||
`tenants?slug=in.(${FOCUS.join(',')})&select=id,slug,name,drive_folder_id`
|
||||
);
|
||||
const need = still.filter((t) => !t.drive_folder_id);
|
||||
if (need.length) {
|
||||
const matches = await matchFoldersToTenants(need, 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 match] ${m.tenantSlug} ← ${m.folderName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Drive rematch failed', e);
|
||||
}
|
||||
|
||||
// Enrich + ensure folders
|
||||
for (const slug of FOCUS) {
|
||||
const t = bySlug[slug];
|
||||
if (!t) continue;
|
||||
await rest(`tenants?id=eq.${t.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Prefer: 'return=minimal' },
|
||||
body: JSON.stringify({
|
||||
name: BRAND_DISPLAY_NAMES[slug],
|
||||
agency_notes: [
|
||||
brandSiblingNote(slug),
|
||||
`Código canónico: ${slug.toUpperCase()}.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
}),
|
||||
});
|
||||
const r = await enrichTenantBrandbook({
|
||||
tenantId: t.id,
|
||||
forceSeed: true,
|
||||
});
|
||||
const drive = await ensureCreativesDriveFolders(t.id, slug);
|
||||
console.log(
|
||||
`[enrich] ${slug} created=${r.created} ${r.completenessBefore}→${r.completenessAfter} sources=${r.sources.join('+')} drive=${drive.ok ? 'ok' : drive.warning}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify brandbooks
|
||||
for (const slug of FOCUS) {
|
||||
const t = bySlug[slug];
|
||||
if (!t) continue;
|
||||
const bb = await rest<{ id: string }[]>(
|
||||
`documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`
|
||||
);
|
||||
console.log(`[verify] ${slug} brandbook=${bb[0]?.id || 'MISSING'}`);
|
||||
}
|
||||
|
||||
console.log('Done');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user