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);
|
||||
});
|
||||
82
scripts/audit_four_brands.ts
Normal file
82
scripts/audit_four_brands.ts
Normal 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);
|
||||
});
|
||||
1711
scripts/complete_brandbooks.py
Normal file
1711
scripts/complete_brandbooks.py
Normal file
File diff suppressed because it is too large
Load Diff
46
scripts/enrich_thin_dna.ts
Normal file
46
scripts/enrich_thin_dna.ts
Normal 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);
|
||||
});
|
||||
132
scripts/ensure_ecp_tsp_drive.ts
Normal file
132
scripts/ensure_ecp_tsp_drive.ts
Normal 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
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);
|
||||
});
|
||||
96
scripts/heal_agency_roles.ts
Normal file
96
scripts/heal_agency_roles.ts
Normal 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);
|
||||
});
|
||||
@@ -97,7 +97,7 @@ const JOBS: ClientJob[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'talento-y-deporte',
|
||||
slug: 'tyd',
|
||||
brand: 'Talento y Deporte',
|
||||
file: `${ROOT}/TYD/CALENDARIO TYD.xlsx`,
|
||||
sheets: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
632
scripts/remediate_brand_truth.py
Normal file
632
scripts/remediate_brand_truth.py
Normal 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()
|
||||
135
scripts/rename_brand_slugs.ts
Normal file
135
scripts/rename_brand_slugs.ts
Normal 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);
|
||||
});
|
||||
272
scripts/split_tenants_and_fix.ts
Normal file
272
scripts/split_tenants_and_fix.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user