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:
4Ventures
2026-10-16 04:15:51 +02:00
parent 3b57cdce53
commit 4a1abd3318
54 changed files with 6400 additions and 527 deletions

401
scripts/audit_all_brands.ts Normal file
View 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);
});

View File

@@ -0,0 +1,82 @@
/**
* Re-enrich TYD/TSP/ECP/DTATF + dump Drive folder scores.
* npx tsx scripts/audit_four_brands.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 { listClientFoldersUnderClientes } from '../src/lib/google-drive';
import { scoreNameToTenant } from '../src/lib/match';
const FOCUS = ['tyd', 'tsp', 'ecp', 'dtatf'] as const;
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = { apikey: key, Authorization: `Bearer ${key}` };
const { folders, clientesRoot } = await listClientFoldersUnderClientes();
console.log(
'Drive root',
clientesRoot?.name || '(none)',
'folders',
folders.length
);
for (const f of [...folders].sort((a, b) => a.name.localeCompare(b.name))) {
console.log(' ', f.name);
}
for (const slug of FOCUS) {
const tr = await fetch(
`${url}/rest/v1/tenants?slug=eq.${slug}&select=id,name,slug,drive_folder_id`,
{ headers: h }
);
const t = (await tr.json())[0] as
| { id: string; name: string; slug: string; drive_folder_id?: string }
| undefined;
if (!t) {
console.log(`[missing] ${slug}`);
continue;
}
const scored = folders
.map((f) => ({
name: f.name,
id: f.id,
score: scoreNameToTenant(f.name, t.name, t.slug),
}))
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 8);
console.log(`\n[${slug}] folder scores`, scored);
const r = await enrichTenantBrandbook({
tenantId: t.id,
forceSeed: true,
});
console.log(
`[enrich] ${slug} ok=${r.ok} created=${r.created} score=${r.completenessAfter} warn=${r.warning || '-'} sources=${r.sources.join('+')}`
);
const drive = await ensureCreativesDriveFolders(t.id, slug);
console.log(
`[creatives-folders] ${slug}`,
drive.ok ? 'ok' : drive.warning
);
const bb = await fetch(
`${url}/rest/v1/documents?tenant_id=eq.${t.id}&type=eq.brandbook&select=id&limit=1`,
{ headers: h }
);
const rows = (await bb.json()) as { id: string }[];
console.log(`[bb] ${slug}`, rows[0]?.id || 'MISSING');
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
/**
* Force-seed enrich for DNA-thin brandbooks after calendar-generate-brand updates.
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
import { enrichTenantBrandbook } from '../src/lib/brandbook-enrich';
const THIN = [
'delgado-abogados',
'equinsa-parking',
'fbm-abogados',
'la-freseria',
'virginia-molet',
'wine-advisors',
];
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const h = {
apikey: key,
Authorization: `Bearer ${key}`,
};
const tenants = (await fetch(
`${url}/rest/v1/tenants?slug=in.(${THIN.join(',')})&select=id,slug`,
{ headers: h }
).then((r) => r.json())) as { id: string; slug: string }[];
for (const t of tenants) {
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
console.log(
t.slug,
`${r.completenessBefore}→${r.completenessAfter}`,
'filled',
(r.filledFields || []).slice(0, 10).join(',') || '-',
r.warning || ''
);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,132 @@
/**
* Create client Drive roots for ECP + TSP under 03-Clientes if missing,
* link integrations + tenants.drive_folder_id, ensure Ads/RRSS creatives folders,
* re-enrich brandbooks.
*
* npx tsx scripts/ensure_ecp_tsp_drive.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 {
ensureChildFolder,
findClientesRootFolder,
listClientFoldersUnderClientes,
} from '../src/lib/google-drive';
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=minimal',
};
const TARGETS: { slug: string; folderName: string }[] = [
{ slug: 'ecp', folderName: '50-ECP Escuela de Cuidadores' },
{ slug: 'tsp', folderName: '51-TSP Talento Scholar Program' },
];
async function main() {
const listed = await listClientFoldersUnderClientes();
const root = listed.clientesRoot || (await findClientesRootFolder());
if (!root?.id) throw new Error('No se encontró carpeta Clientes en Drive');
console.log('Clientes root', root.name, root.id);
const folders = listed.folders;
for (const t of TARGETS) {
const tr = await fetch(
`${url}/rest/v1/tenants?slug=eq.${t.slug}&select=id,slug,name`,
{ headers: h }
);
const tenant = (await tr.json())[0] as
| { id: string; slug: string; name: string }
| undefined;
if (!tenant) {
console.log(`[skip] ${t.slug} no existe`);
continue;
}
let folder = folders.find((f) =>
t.slug === 'ecp'
? /ecp|cuidadores/i.test(f.name) && !/navidad/i.test(f.name)
: /tsp|scholar/i.test(f.name)
);
if (!folder) {
const id = await ensureChildFolder(root.id, t.folderName);
folder = { id, name: t.folderName };
console.log(`[create] ${t.folderName} → ${id}`);
} else {
console.log(`[exist] ${folder.name} → ${folder.id}`);
}
await fetch(
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive`,
{
method: 'PATCH',
headers: h,
body: JSON.stringify({
status: 'connected',
enabled: true,
external_account_id: folder.id,
label: folder.name,
updated_at: new Date().toISOString(),
}),
}
);
// Upsert if no google_drive row
const check = await fetch(
`${url}/rest/v1/integrations?tenant_id=eq.${tenant.id}&provider=eq.google_drive&select=id`,
{ headers: h }
);
const rows = (await check.json()) as { id: string }[];
if (!rows.length) {
await fetch(`${url}/rest/v1/integrations`, {
method: 'POST',
headers: h,
body: JSON.stringify({
tenant_id: tenant.id,
provider: 'google_drive',
status: 'connected',
enabled: true,
external_account_id: folder.id,
label: folder.name,
}),
});
}
await fetch(`${url}/rest/v1/tenants?id=eq.${tenant.id}`, {
method: 'PATCH',
headers: h,
body: JSON.stringify({ drive_folder_id: folder.id }),
});
const creatives = await ensureCreativesDriveFolders(tenant.id, t.slug);
console.log(
`[creatives] ${t.slug}`,
creatives.ok ? 'Ads+RRSS ok' : creatives.warning
);
const enrich = await enrichTenantBrandbook({
tenantId: tenant.id,
forceSeed: true,
});
console.log(
`[enrich] ${t.slug} ok=${enrich.ok} score=${enrich.completenessAfter} warn=${enrich.warning || '-'}`
);
}
console.log('Done');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

232
scripts/fix_brand_gaps.ts Normal file
View 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);
});

View File

@@ -0,0 +1,96 @@
/**
* Asegura app_metadata.role=admin (o team) para operadores @4ventures.es.
* npx tsx scripts/heal_agency_roles.ts
*/
import { config } from 'dotenv';
import { resolve } from 'path';
import { createClient } from '@supabase/supabase-js';
config({ path: resolve(process.cwd(), '.env.local') });
const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
const COMPANY = '4Ventures';
const SLUG = '4ventures-hub';
const FORCE_ADMIN = [
'marketing@4ventures.es',
'marcos@4ventures.es',
'belen@4ventures.es',
'rebecca@4ventures.es',
'victor@4ventures.es',
'agencia@4ventures.es',
'ops@4ventures.es',
];
async function main() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
if (!url || !key) throw new Error('Faltan env Supabase');
const admin = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
const emails = new Set(FORCE_ADMIN.map((e) => e.toLowerCase()));
let page = 1;
let fixed = 0;
let scanned = 0;
for (;;) {
const { data, error } = await admin.auth.admin.listUsers({
page,
perPage: 200,
});
if (error) throw error;
const users = data.users || [];
if (!users.length) break;
for (const u of users) {
scanned += 1;
const email = (u.email || '').toLowerCase();
if (!email.endsWith('@4ventures.es') && !emails.has(email)) continue;
const appRole = String(u.app_metadata?.role || '');
if (appRole === 'admin' || appRole === 'team') {
// Ya OK; repara tenant/company si faltan
if (u.app_metadata?.tenant_id && u.app_metadata?.company) continue;
}
if (appRole === 'client') {
console.log(`skip client explícito: ${email}`);
continue;
}
const role = emails.has(email) ? 'admin' : 'team';
const { error: updErr } = await admin.auth.admin.updateUserById(u.id, {
app_metadata: {
...(u.app_metadata || {}),
role,
tenant_id: u.app_metadata?.tenant_id || HUB_TENANT,
company: u.app_metadata?.company || COMPANY,
slug: u.app_metadata?.slug || SLUG,
},
user_metadata: {
...(u.user_metadata || {}),
role,
company: COMPANY,
},
});
if (updErr) {
console.error(`FAIL ${email}:`, updErr.message);
} else {
fixed += 1;
console.log(`OK ${email} → app_metadata.role=${role}`);
}
}
if (users.length < 200) break;
page += 1;
}
console.log(`Scanned ${scanned}, fixed ${fixed}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -97,7 +97,7 @@ const JOBS: ClientJob[] = [
],
},
{
slug: 'talento-y-deporte',
slug: 'tyd',
brand: 'Talento y Deporte',
file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`,
sheets: {

View File

@@ -50,7 +50,7 @@ DRIVE_SHEETS = {
# Single "Hoja 1" sheet — ops calendar for Oct 2026 (Jueves 1 also matches enero).
"force_sheet_months": {"Hoja 1": "2026-10"},
},
"talento-y-deporte": {
"tyd": {
"brand": "Talento y Deporte",
"file_id": "1HSbJAfjlBDdpHJubgxhT322UUZVJ2zlQHaPkO0HtevA",
},

View File

@@ -0,0 +1,632 @@
#!/usr/bin/env python3
"""
Remedia brandbooks: elimina kits inventados y aplica solo señales
verificadas desde webs oficiales (+ notas de procedencia).
"""
from __future__ import annotations
import json
import random
import time
import urllib.request
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
def env(k: str) -> str:
for line in (ROOT / ".env.local").read_text().splitlines():
if line.startswith(k + "="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
return ""
URL = env("NEXT_PUBLIC_SUPABASE_URL")
KEY = env("SUPABASE_SERVICE_ROLE_KEY")
HR = {"apikey": KEY, "Authorization": f"Bearer {KEY}"}
HW = {**HR, "Content-Type": "application/json", "Prefer": "return=minimal"}
def get(path: str) -> Any:
req = urllib.request.Request(f"{URL}/rest/v1/{path}", headers=HR)
return json.loads(urllib.request.urlopen(req).read())
def patch(path: str, body: dict) -> None:
req = urllib.request.Request(
f"{URL}/rest/v1/{path}",
data=json.dumps(body).encode(),
headers=HW,
method="PATCH",
)
urllib.request.urlopen(req)
def nid(p: str) -> str:
return f"{p}_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
def color(name: str, hex_: str, role: str, usage: str) -> dict:
return {
"id": nid("col"),
"name": name,
"hex": hex_.upper() if hex_.startswith("#") else hex_,
"role": role,
"usage": usage,
}
def font(family: str, role: str, source: str, notes: str) -> dict:
return {
"id": nid("font"),
"family": family,
"role": role,
"weights": "",
"source": source,
"url_or_notes": notes,
}
# Solo colores con evidencia fuerte en HTML/CSS de la web oficial.
# Si no hay evidencia clara → colors = [] (mejor vacío que inventado).
VERIFIED_VISUAL: dict[str, dict[str, Any]] = {
"4ventures-hub": {
"colors": [
color(
"Naranja marca (web)",
"#E05200",
"primary",
"Frecuencia alta en CSS de https://4ventures.es/",
),
],
"fonts_kit": [
font(
"DM Sans",
"headline",
"google",
"font-family detectado en HTML de 4ventures.es",
),
font(
"Cormorant Garamond",
"display",
"google",
"font-family detectado en HTML de 4ventures.es",
),
],
"source": "HTML/CSS https://4ventures.es/",
},
"agua-miia": {
"colors": [
color(
"Azul primario",
"#0085F2",
"primary",
"CSS var marca en https://aguamiia.es/",
),
color(
"Azul corporativo",
"#2071CF",
"secondary",
"CSS var / uso frecuente en aguamiia.es",
),
color(
"Azul oscuro",
"#0F4F9E",
"neutral",
"CSS var en aguamiia.es",
),
color(
"Verde acento",
"#2E9B76",
"accent",
"CSS var en aguamiia.es",
),
],
"fonts_kit": [
font(
"Urbanist",
"headline",
"google",
"font-family dominante en HTML de aguamiia.es",
),
font(
"Nunito",
"body",
"google",
"font-family secundario en HTML de aguamiia.es",
),
],
"source": "CSS variables + font-family https://aguamiia.es/",
},
"bps": {
# BPS ya tenía kit; reforzamos con theme-color oficial
"colors": [
color(
"BPS Blue",
"#0051AA",
"primary",
"theme-color meta + uso en https://blueparrotschool.com/",
),
color(
"Accent amarillo",
"#FDC526",
"accent",
"Hex frecuente en CSS de blueparrotschool.com",
),
color(
"Ink",
"#201E22",
"neutral",
"Hex frecuente en CSS de blueparrotschool.com",
),
],
"fonts_kit": [
font(
"Figtree",
"headline",
"google",
"Referenciado en HTML blueparrotschool.com",
),
font(
"Jost",
"body",
"google",
"Referenciado en HTML blueparrotschool.com",
),
],
"source": "theme-color + CSS https://blueparrotschool.com/",
"merge_colors_if_existing": True,
},
"brava-dermoestetica": {
"colors": [
color(
"Rosa/brandy clínica",
"#7A3F4D",
"primary",
"Hex dominante (69×) en https://www.bravadermoestetica.com/",
),
color(
"Rosa oscuro",
"#61323D",
"secondary",
"Hex frecuente en bravadermoestetica.com",
),
color(
"Fondo cálido",
"#F8F5F2",
"neutral",
"Hex de fondo en bravadermoestetica.com",
),
],
"fonts_kit": [
font(
"Playfair Display",
"headline",
"google",
"font-family en HTML bravadermoestetica.com",
),
font(
"Inter",
"body",
"google",
"font-family en HTML bravadermoestetica.com",
),
font(
"Myriad Pro",
"other",
"adobe",
"font-family referenciado en HTML (licencia Adobe/sistema)",
),
],
"source": "HTML/CSS https://www.bravadermoestetica.com/",
},
"delgado-abogados": {
"colors": [
color(
"Azul enlace/marca",
"#1863DC",
"primary",
"Hex frecuente en https://abogadosdelgado.es/",
),
color(
"Ink",
"#212121",
"neutral",
"Hex frecuente en abogadosdelgado.es",
),
],
"fonts_kit": [
font(
"Inter",
"body",
"google",
"WP preset font-family Inter en abogadosdelgado.es",
),
],
"source": "HTML/CSS https://abogadosdelgado.es/ (parcial; ampliar con brand kit PDF)",
},
"dtatf": {
"colors": [
color(
"Amarillo De Tú a Tú",
"#F6A800",
"primary",
"Hex en CSS/HTML https://detuatuformacion.es/",
),
color(
"Azul oscuro De Tú a Tú",
"#273645",
"secondary",
"Hex en CSS/HTML detuatuformacion.es (+ #253645)",
),
],
"fonts_kit": [
font(
"Poppins-DeTUaTU",
"headline",
"file",
"Familia custom referenciada en detuatuformacion.es (archivo/fuente propia)",
),
],
"source": "HTML/CSS https://detuatuformacion.es/",
},
"ecp": {
"colors": [
color(
"Amarillo ECP",
"#F7A801",
"primary",
"Hex dominante en https://escueladecuidadoresprofesionales.com/",
),
color(
"Azul",
"#1863DC",
"secondary",
"Hex frecuente en web ECP",
),
color(
"Ink",
"#212121",
"neutral",
"Hex frecuente en web ECP",
),
],
"fonts_kit": [
font(
"Raleway",
"headline",
"google",
"font-family dominante en web ECP",
),
font(
"Open Sans",
"body",
"google",
"font-family secundario en web ECP",
),
],
"source": "HTML/CSS https://escueladecuidadoresprofesionales.com/",
},
"equinsa-parking": {
"colors": [
color(
"Navy Equinsa",
"#000028",
"primary",
"Hex en https://equinsaparking.com/",
),
color(
"Azul",
"#003388",
"secondary",
"Hex en equinsaparking.com",
),
color(
"Azul enlace",
"#046BD2",
"accent",
"CSS var / hex en equinsaparking.com",
),
],
"fonts_kit": [
font(
"Roboto",
"body",
"google",
"font-family dominante en equinsaparking.com",
),
],
"source": "HTML/CSS https://equinsaparking.com/",
},
"fbm-abogados": {
"colors": [
color(
"Navy FBM",
"#04243C",
"primary",
"Hex dominante (75×) en https://fbmasociados.com/",
),
],
"fonts_kit": [], # tahoma genérico — no afirmar kit de marca
"source": "HTML/CSS https://fbmasociados.com/ (color). Tipografía: pendiente brand kit.",
},
"halal-safety": {
"colors": [], # scrape débil / ruido WP
"fonts_kit": [
font(
"Manrope",
"body",
"google",
"Referenciado en HTML halalsafety.com",
),
],
"source": "Tipografía HTML https://www.halalsafety.com/. Colores: insuficientes en scrape — vacíos a propósito.",
},
"la-freseria": {
"colors": [], # el scrape pilló azules de template Framer, NO son marca fresa
"fonts_kit": [
font(
"La Fresería",
"display",
"file",
"Familia custom 'La Fresería' referenciada en lafreseria.es (archivo propio)",
),
],
"source": "Fuentes custom en https://lafreseria.es/. Colores NO aplicados: el HTML mezcla tokens Framer (#046bd2) no confiables como marca.",
},
"noberplast": {
"colors": [
color(
"Azul Noberplast",
"#0072AB",
"primary",
"Hex en https://noberplast.com/",
),
color(
"Verde",
"#06A161",
"secondary",
"Hex en noberplast.com",
),
color(
"Rojo acento",
"#DB062C",
"accent",
"Hex en noberplast.com",
),
],
"fonts_kit": [],
"source": "Hex en https://noberplast.com/. Tipografía: no detectada con claridad.",
},
"tsp": {
"colors": [
color(
"Navy Scholar",
"#161B2F",
"primary",
"Hex frecuente en https://talentoscholarprogram.com/",
),
color(
"Azul claro",
"#A4CAEF",
"secondary",
"Hex frecuente en talentoscholarprogram.com",
),
color(
"Rojo acento",
"#A40406",
"accent",
"Hex en talentoscholarprogram.com",
),
],
"fonts_kit": [
font(
"Poppins",
"body",
"google",
"font-family dominante en talentoscholarprogram.com",
),
font(
"Fraunces",
"display",
"google",
"font-family en talentoscholarprogram.com",
),
],
"source": "HTML/CSS https://talentoscholarprogram.com/",
},
"tyd": {
"colors": [
color(
"Rojo TYD",
"#E92428",
"primary",
"Hex dominante en https://talentoydeporte.com/",
),
color(
"Azul TYD",
"#004885",
"secondary",
"Hex en talentoydeporte.com",
),
],
"fonts_kit": [
font(
"Montserrat",
"headline",
"google",
"font-family en talentoydeporte.com",
),
font(
"Oswald",
"display",
"google",
"font-family referenciado en talentoydeporte.com",
),
],
"source": "HTML/CSS https://talentoydeporte.com/",
},
"virginia-molet": {
"colors": [
color(
"Azul Gravitae",
"#004271",
"primary",
"Hex frecuente en https://gravitaeacademy.com/",
),
color(
"Azul enlace",
"#1863DC",
"secondary",
"Hex frecuente en gravitaeacademy.com (posible token WP; contrastar con kit)",
),
],
"fonts_kit": [
font(
"quincy-cf",
"display",
"adobe",
"font-family 'quincy-cf' en gravitaeacademy.com",
),
font(
"Inter",
"body",
"google",
"font-family Inter en gravitaeacademy.com",
),
],
"source": "HTML/CSS https://gravitaeacademy.com/ (revisar azul #1863DC vs kit oficial)",
},
"wine-advisors": {
"colors": [
color(
"Rojo WineAdvisor",
"#CC0100",
"primary",
"Hex detectado en https://www.wineadvisor.mx/",
),
],
"fonts_kit": [],
"source": "Hex limitado en https://www.wineadvisor.mx/. Tipografía: no detectada.",
},
}
PROVENANCE_HEADER = (
"[PROCEDENCIA 2026-10-15] "
"Webs/handles/LinkedIn: contrastados con sitio oficial + Meta business_discovery + páginas LinkedIn. "
"Colores/fuentes: solo si aparecen en HTML/CSS de la web (ver visual_notes). "
"Textos de posicionamiento/tono/ICP: síntesis de brand manager a partir del copy público de la web "
"(no son citas literales del cliente ni de un PDF de marca). "
"Si un campo no tiene evidencia → vacío. "
)
def parse(c: Any) -> dict:
if isinstance(c, str):
return json.loads(c)
return dict(c or {})
def main() -> None:
tenants = get(
"tenants?agency_followed=neq.false&select=id,slug,name&order=slug"
)
for t in tenants:
slug = t["slug"]
docs = get(
f"documents?tenant_id=eq.{t['id']}&type=eq.brandbook&select=id,content&order=updated_at.desc&limit=1"
)
if not docs:
print("skip", slug)
continue
book = parse(docs[0]["content"])
vis = VERIFIED_VISUAL.get(slug)
if vis is None:
# no verified visual → clear invented kits
book["colors"] = []
book["fonts_kit"] = []
book["colors_primary"] = []
book["colors_secondary"] = []
book["fonts"] = []
note = "Sin señales visuales verificables en scrape; kit vaciado."
else:
book["colors"] = vis.get("colors") or []
book["fonts_kit"] = vis.get("fonts_kit") or []
book["colors_primary"] = [
c["hex"] for c in book["colors"] if c.get("role") == "primary"
]
book["colors_secondary"] = [
c["hex"]
for c in book["colors"]
if c.get("role") in ("secondary", "accent")
]
book["fonts"] = [f["family"] for f in book["fonts_kit"]]
if book["fonts_kit"]:
heads = [f for f in book["fonts_kit"] if f["role"] in ("headline", "display")]
bodies = [f for f in book["fonts_kit"] if f["role"] == "body"]
if heads:
book["typography_headline"] = heads[0]["family"]
if bodies:
book["typography_body"] = bodies[0]["family"]
note = vis.get("source") or ""
# Visual notes: honest provenance
book["visual_notes"] = (
f"PROCEDENCIA VISUAL: {note}. "
"Los hex/fuentes listados salen del HTML/CSS público. "
"Sustituir por brand kit oficial (PDF/AI) cuando esté en Drive/Zona de marca. "
"NO usar paletas inventadas por IA."
)
# Provenance in freeform
prev = (book.get("freeform_notes") or "").strip()
# remove older provenance blocks to avoid duplication
lines = [
ln
for ln in prev.split("\n")
if not ln.startswith("[PROCEDENCIA")
and "PROCEDENCIA VISUAL" not in ln
]
book["freeform_notes"] = (
PROVENANCE_HEADER
+ (f"Visual: {note}. " if note else "")
+ ("\n" + "\n".join(lines) if lines else "")
).strip()
# Clear weak invented claims that often aren't on-site
# Keep website/handles/products — those were verified in packs
# Soften proof_points that cite unverified stats
if slug == "la-freseria":
# don't keep invented franchise € figures as hard fact without date stamp
pps = book.get("proof_points") or []
book["proof_points"] = [
p
for p in pps
if "65.000" not in p and "EBITDA" not in p
] + [
"Claim web/franquicia: 'las fresas más famosas del mundo' (lafreseria.es)",
"IG oficial @lafreseriaoficial",
"LinkedIn company/la-freseria",
]
if slug == "equinsa-parking":
# keep qualitative; avoid conflicting installation counts
book["proof_points"] = [
"Web: especializada en sistemas de gestión de aparcamientos (equinsaparking.com)",
"LinkedIn company/equinsa-parking",
"Sede comunicada: C. Primavera 16, Torrejón de Ardoz (web)",
"Contacto web: sat@equinsa.es / +34 618 18 89 24",
]
patch(f"documents?id=eq.{docs[0]['id']}", {"content": book})
n_col = len(book.get("colors") or [])
n_font = len(book.get("fonts_kit") or [])
print(f"[ok] {slug:22} colors={n_col} fonts={n_font} · {note[:80]}")
print("DONE — invented kits removed; verified web signals applied")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,135 @@
/**
* Rename portfolio brand slugs to canonical codes:
* talento-y-deporte → tyd
* talento-scholar-program → tsp
* de-tu-a-tu-formacion → dtatf
* ecp → ecp (unchanged)
*
* npx tsx scripts/rename_brand_slugs.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 {
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 RENAMES: { from: string; to: keyof typeof BRAND_DISPLAY_NAMES }[] = [
{ from: 'talento-y-deporte', to: 'tyd' },
{ from: 'talento-scholar-program', to: 'tsp' },
{ from: 'de-tu-a-tu-formacion', to: 'dtatf' },
];
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 main() {
const today = new Date().toISOString().slice(0, 10);
const tenants = await rest<{ id: string; slug: string; name: string; agency_notes?: string }[]>(
'tenants?select=id,slug,name,agency_notes'
);
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
for (const { from, to } of RENAMES) {
const existingTo = bySlug[to];
const fromRow = bySlug[from];
if (existingTo && !fromRow) {
console.log(`[${to}] already canonical`);
continue;
}
if (!fromRow) {
console.log(`[skip] ${from} not found`);
continue;
}
if (existingTo && existingTo.id !== fromRow.id) {
throw new Error(`Conflict: both ${from} and ${to} exist`);
}
const sib = brandSiblingNote(to);
const notes = [
`[${today}] Slug canónico: ${to} · ${BRAND_DISPLAY_NAMES[to]}.`,
sib,
fromRow.agency_notes || '',
]
.filter(Boolean)
.join('\n')
.slice(0, 4000);
await rest(`tenants?id=eq.${fromRow.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
slug: to,
name: BRAND_DISPLAY_NAMES[to],
agency_followed: true,
agency_notes: notes,
}),
});
console.log(`[rename] ${from} → ${to}`);
bySlug[to] = { ...fromRow, slug: to, name: BRAND_DISPLAY_NAMES[to] };
delete bySlug[from];
}
// Ensure ECP display name
const ecp = bySlug['ecp'];
if (ecp) {
await rest(`tenants?id=eq.${ecp.id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({
name: BRAND_DISPLAY_NAMES.ecp,
agency_followed: true,
agency_notes: [
`[${today}] Marca canónica ECP (separada de DTATF).`,
brandSiblingNote('ecp'),
ecp.agency_notes || '',
]
.filter(Boolean)
.join('\n')
.slice(0, 4000),
}),
});
console.log('[ecp] display name refreshed');
}
for (const slug of ['tyd', 'tsp', 'ecp', 'dtatf'] as const) {
const t = bySlug[slug] || (await rest<{ id: string; slug: string }[]>(
`tenants?slug=eq.${slug}&select=id,slug&limit=1`
))[0];
if (!t) {
console.log(`[missing] ${slug}`);
continue;
}
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
const drive = await ensureCreativesDriveFolders(t.id, slug);
console.log(
`[enrich] ${slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
);
}
console.log('Done');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,272 @@
/**
* Portfolio fix:
* - Menttum: confirm archived (no longer a client)
* - De Tú a Tú Formación ↔ ECP: separate as two active clients (move ECP social/ads)
* - Create Talento Scholar Program as sibling of Talento y Deporte
* - Sync tenants.drive_folder_id from google_drive integrations
* - Seed DNA + enrich brandbooks for affected + active portfolio
*
* npx tsx scripts/split_tenants_and_fix.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';
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',
};
async function rest<T = unknown>(
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)}`);
if (!text) return [] as T;
return JSON.parse(text) as T;
}
async function patchTenant(
id: string,
patch: Record<string, unknown>
): Promise<void> {
await rest(`tenants?id=eq.${id}`, {
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify(patch),
});
}
async function patchIntegration(
tenantId: string,
provider: string,
patch: Record<string, unknown>
): Promise<void> {
await rest(
`integrations?tenant_id=eq.${tenantId}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: { Prefer: 'return=minimal' },
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
}
);
}
async function main() {
const today = new Date().toISOString().slice(0, 10);
const tenants = await rest<
{
id: string;
slug: string;
name: string;
agency_followed?: boolean;
agency_notes?: string | null;
drive_folder_id?: string | null;
}[]
>('tenants?select=id,slug,name,agency_followed,agency_notes,drive_folder_id&order=name.asc');
const bySlug = Object.fromEntries(tenants.map((t) => [t.slug, t]));
// ——— 1. Menttum: archived, not a client ———
const menttum = bySlug['menttum'];
if (menttum) {
await patchTenant(menttum.id, {
agency_followed: false,
agency_notes: `[archivado ${today}] Ya no es cliente 4Ventures. No enriquecer / no sincronizar creatividades.\n${menttum.agency_notes || ''}`.slice(
0,
4000
),
creatives_enabled: false,
});
for (const p of [
'meta_ads',
'facebook',
'instagram',
'google_ads',
'google_drive',
'ga4',
'search_console',
]) {
await patchIntegration(menttum.id, p, {
status: 'disconnected',
enabled: false,
}).catch(() => undefined);
}
console.log('[menttum] archived + integrations disconnected');
}
// ——— 2. Separate De Tú a Tú vs ECP ———
const detu = bySlug['de-tu-a-tu-formacion'];
const ecp = bySlug['ecp'];
if (!detu || !ecp) throw new Error('missing de-tu-a-tu-formacion or ecp');
// Activate ECP as its own client
await patchTenant(ecp.id, {
agency_followed: true,
name: 'ECP · Escuela de Cuidadores',
creatives_enabled: false,
default_landing_url: 'https://escueladecuidadoresprofesionales.com',
meta_ad_account_id: 'act_1320801442642004',
meta_page_id: '111175967913754',
meta_ig_user_id: '17841453624785447',
agency_notes: `[${today}] Cliente separado de De Tú a Tú Formación. Marca: Escuela de Cuidadores Profesionales (ECP). Meta act_1320801442642004 · IG @escuelaecp · web escueladecuidadoresprofesionales.com. Drive: buscar carpeta cliente ECP/Cuidadores (evitar «Navidad ecp» si no es raíz).`,
});
// Move ECP social/ads ownership onto ECP tenant (connect ECP rows)
await patchIntegration(ecp.id, 'meta_ads', {
status: 'connected',
enabled: true,
external_account_id: 'act_1320801442642004',
label: 'ECP',
});
await patchIntegration(ecp.id, 'facebook', {
status: 'connected',
enabled: true,
external_account_id: '111175967913754',
label: 'Escuela de cuidadores profesionales',
});
await patchIntegration(ecp.id, 'instagram', {
status: 'connected',
enabled: true,
external_account_id: '17841453624785447',
label: '@escuelaecp',
});
await patchIntegration(ecp.id, 'ga4', {
status: 'connected',
enabled: true,
external_account_id: 'G-82HW868X9L',
label: 'Web escueladecuidadoresprofesionales.com - GA4',
});
// Detach ECP assets from De Tú a Tú
await patchIntegration(detu.id, 'meta_ads', {
status: 'disconnected',
enabled: false,
external_account_id: 'act_413393941247349',
label: 'Detuatuformacion (pendiente permiso API system user)',
});
await patchIntegration(detu.id, 'facebook', {
status: 'disconnected',
enabled: false,
external_account_id: null,
label: 'Facebook Page (pendiente página propia De Tú a Tú)',
});
await patchIntegration(detu.id, 'instagram', {
status: 'disconnected',
enabled: false,
external_account_id: null,
label: 'Instagram (pendiente cuenta propia De Tú a Tú)',
});
await patchTenant(detu.id, {
agency_followed: true,
name: 'De Tú a Tú Formación',
creatives_enabled: false,
meta_ad_account_id: 'act_413393941247349',
meta_page_id: null,
meta_ig_user_id: null,
default_landing_url: null,
drive_folder_id: '1PWUL2sn18n9bIURwfuc8YIKfhbl1BQfg',
agency_notes: `[${today}] Separado de ECP. Marca De Tú a Tú Formación. Google Ads 3183140753 · Drive 08-Detuatu · Meta act_413393941247349 (otorgar ads_read al system user API 4V2030). RRSS/Meta page propias pendientes; ECP ya no se gestiona aquí.`,
});
console.log('[detuatu / ecp] separated');
// ——— 3. Create Talento Scholar Program ———
let scholar = bySlug['talento-scholar-program'];
if (!scholar) {
const created = await rest<
{ id: string; slug: string; name: string }[]
>('tenants', {
method: 'POST',
body: JSON.stringify({
slug: 'talento-scholar-program',
name: 'Talento Scholar Program',
agency_followed: true,
creatives_enabled: false,
agency_notes: `[${today}] Separado de Talento y Deporte. Línea académica / Scholar (becas de estudio USA). Emparejar Meta Ads, IG y Drive propios cuando existan.`,
default_landing_url: 'https://talentoydeporte.com',
}),
});
scholar = created[0];
console.log('[talento-scholar-program] created', scholar?.id);
} else {
await patchTenant(scholar.id, {
agency_followed: true,
name: 'Talento Scholar Program',
agency_notes: `[${today}] Cliente separado de Talento y Deporte · línea Scholar / becas académicas.\n${scholar.agency_notes || ''}`.slice(
0,
4000
),
});
console.log('[talento-scholar-program] already existed — activated');
}
// Seed empty integrations row set is usually done by app on first open;
// ensure notes on Talento y Deporte clarify split
const tyd = bySlug['talento-y-deporte'];
if (tyd) {
await patchTenant(tyd.id, {
agency_followed: true,
name: 'Talento y Deporte',
drive_folder_id: '1IwXdvcVFKpHta4jf3nxT7k7qltzSufuL',
default_landing_url: 'https://talentoydeporte.com',
meta_ad_account_id: 'act_1188310622211934',
meta_page_id: '189855164776887',
meta_ig_user_id: '17841405373441110',
agency_notes: `[${today}] Línea deportiva / becas deportivas USA. Sibling: Talento Scholar Program (académico). Drive 06-Talento y Deporte · IG @talentoydeporte.`,
});
console.log('[talento-y-deporte] notes + ids synced');
}
// ——— 4. Sync drive_folder_id from google_drive for all followed tenants ———
const driveRows = await rest<
{ tenant_id: string; external_account_id: string; label?: string }[]
>(
'integrations?provider=eq.google_drive&status=eq.connected&select=tenant_id,external_account_id,label'
);
for (const row of driveRows) {
// Skip bad ECP christmas folder as root — keep label but don't force as client root if name is Navidad
if (/navidad/i.test(row.label || '')) {
console.log(
`[drive] skip suspicious folder for tenant ${row.tenant_id}: ${row.label}`
);
continue;
}
await patchTenant(row.tenant_id, {
drive_folder_id: row.external_account_id,
});
}
console.log(`[drive] synced folder ids for ${driveRows.length} tenants`);
// ——— 5. Enrich brandbooks for active portfolio (skip archived) ———
const active = await rest<{ id: string; slug: string; name: string }[]>(
'tenants?agency_followed=neq.false&select=id,slug,name&order=name.asc'
);
for (const t of active) {
const r = await enrichTenantBrandbook({ tenantId: t.id, forceSeed: true });
const drive = await ensureCreativesDriveFolders(t.id, t.slug);
console.log(
`[enrich] ${t.slug} ${r.completenessBefore}→${r.completenessAfter} drive=${drive.ok ? 'ok' : drive.warning}`
);
}
console.log('Done · active tenants', active.length);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});