Associate Detuatuformacion Meta ad account with De Tú A Tú.

Support multiple Meta ad accounts per tenant in creatives/sync UI so ECP and Detuatuformacion can both be linked.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-09 10:20:00 +02:00
parent f316497650
commit 8723753066
3 changed files with 123 additions and 52 deletions

View File

@@ -13,7 +13,15 @@ type Integ = {
error_message?: string | null; error_message?: string | null;
last_sync_at?: string | null; last_sync_at?: string | null;
enabled?: boolean; enabled?: boolean;
meta?: { folder_name?: string; match_score?: number } | null; meta?: {
folder_name?: string;
match_score?: number;
extra_ad_accounts?: {
account_id?: string;
external_account_id?: string;
name?: string;
}[];
} | null;
}; };
type Provider = { id: string; label: string; group: string; live?: boolean }; type Provider = { id: string; label: string; group: string; live?: boolean };
@@ -354,6 +362,18 @@ export default function ClientIntegrationsPanel({
TikTok Ads. OAuth live y publicación: siguiente fase. TikTok Ads. OAuth live y publicación: siguiente fase.
</p> </p>
)} )}
{p.id === 'meta_ads' &&
(row?.meta?.extra_ad_accounts || []).length ? (
<p className="text-[10px] text-sky-800 bg-sky-50 border border-sky-100 rounded-lg px-2 py-1.5">
Cuentas extra:{' '}
{(row?.meta?.extra_ad_accounts || [])
.map(
(x) =>
`${x.name || 'cuenta'} (${x.account_id || x.external_account_id})`
)
.join(' · ')}
</p>
) : null}
</li> </li>
); );
})} })}

View File

@@ -394,7 +394,18 @@ export default function WindsorAiSection({
}, },
{ {
name: 'Meta Ads', name: 'Meta Ads',
id: meta?.external_account_id || meta?.label || '—', id: (() => {
const primary = meta?.external_account_id || '';
const extras = (
(meta?.meta as { extra_ad_accounts?: { account_id?: string; external_account_id?: string; name?: string }[] } | null)
?.extra_ad_accounts || []
)
.map((x) => x.account_id || x.external_account_id)
.filter(Boolean);
const ids = [primary, ...extras].filter(Boolean);
if (ids.length) return ids.join(' · ');
return meta?.label || '—';
})(),
status: meta?.status === 'connected' ? 'connected' : 'disconnected', status: meta?.status === 'connected' ? 'connected' : 'disconnected',
type: 'Instagram & Facebook Ads', type: 'Instagram & Facebook Ads',
}, },

View File

@@ -411,18 +411,42 @@ function metaCardsFromDaily(
}); });
} }
async function resolveMetaAccountId(tenantId: string): Promise<string | null> { async function resolveMetaAccountIds(tenantId: string): Promise<string[]> {
const headers = serviceHeaders(); const headers = serviceHeaders();
const base = supabaseUrl(); const base = supabaseUrl();
if (!headers || !base) return null; if (!headers || !base) return [];
const r = await fetch( const r = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.meta_ads&select=external_account_id,status&limit=1`, `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
tenantId
)}&provider=eq.meta_ads&select=external_account_id,status,meta&limit=1`,
{ headers, cache: 'no-store' } { headers, cache: 'no-store' }
); );
if (!r.ok) return null; if (!r.ok) return [];
const rows = (await r.json()) as { external_account_id?: string | null }[]; const rows = (await r.json()) as {
const id = rows[0]?.external_account_id?.trim(); external_account_id?: string | null;
return id || null; meta?: {
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[];
alternate_accounts?: { external_account_id?: string }[];
} | null;
}[];
const row = rows[0];
const out: string[] = [];
const primary = row?.external_account_id?.trim();
if (primary) out.push(primary);
for (const x of row?.meta?.extra_ad_accounts || []) {
const id = (x.account_id || x.external_account_id || '').trim();
if (id) out.push(id);
}
for (const x of row?.meta?.alternate_accounts || []) {
const id = (x.external_account_id || '').trim();
if (id) out.push(id);
}
return [...new Set(out)];
}
async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
const ids = await resolveMetaAccountIds(tenantId);
return ids[0] || null;
} }
async function resolveImageHashes( async function resolveImageHashes(
@@ -771,13 +795,14 @@ async function fetchMetaCreativeCardsInner(
: { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' }; : { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
} }
const account = await resolveMetaAccountId(opts.tenantId); const accounts = await resolveMetaAccountIds(opts.tenantId);
if (!account) { if (!accounts.length) {
const ads = warehouse(); const ads = warehouse();
return ads.length return ads.length
? { ads } ? { ads }
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
} }
const account = accounts[0];
const limit = opts.limit ?? 16; const limit = opts.limit ?? 16;
let targets: { let targets: {
@@ -790,17 +815,21 @@ async function fetchMetaCreativeCardsInner(
conversions: number; conversions: number;
}[] = []; }[] = [];
for (const actId of accounts) {
if (targets.length >= limit) break;
try { try {
targets = await fetchMetaAdLevelInsights( const part = await fetchMetaAdLevelInsights(
account, actId,
token, token,
opts.since, opts.since,
opts.until, opts.until,
limit limit - targets.length
); );
targets.push(...part);
} catch { } catch {
/* fall through — never surface ad-level Graph errors */ /* fall through — never surface ad-level Graph errors */
} }
}
// Fallback: expand campaigns from ad_daily → ads under each campaign // Fallback: expand campaigns from ad_daily → ads under each campaign
if (!targets.length) { if (!targets.length) {
@@ -914,10 +943,15 @@ export async function fetchMetaAccountClickSplit(opts: {
}): Promise<{ clicks: number; linkClicks: number; spend: number } | null> { }): Promise<{ clicks: number; linkClicks: number; spend: number } | null> {
const token = await resolveMetaAccessToken(); const token = await resolveMetaAccessToken();
if (!token) return null; if (!token) return null;
const account = await resolveMetaAccountId(opts.tenantId); const accounts = await resolveMetaAccountIds(opts.tenantId);
if (!account) return null; if (!accounts.length) return null;
const act = account.startsWith('act_') ? account : `act_${account}`;
const timeRange = JSON.stringify({ since: opts.since, until: opts.until }); const timeRange = JSON.stringify({ since: opts.since, until: opts.until });
let clicks = 0;
let linkClicks = 0;
let spend = 0;
let any = false;
for (const account of accounts) {
const act = account.startsWith('act_') ? account : `act_${account}`;
try { try {
const raw = await graphGet<{ const raw = await graphGet<{
data?: { data?: {
@@ -937,14 +971,20 @@ export async function fetchMetaAccountClickSplit(opts: {
token token
); );
const row = raw.data?.[0]; const row = raw.data?.[0];
if (!row) return null; if (!row) continue;
const clicks = n(row.clicks); any = true;
const c = n(row.clicks);
clicks += c;
linkClicks += linkClicksFromMetaActions(row.actions, c);
spend += n(row.spend);
} catch {
/* skip inaccessible account */
}
}
if (!any) return null;
return { return {
clicks, clicks,
linkClicks: linkClicksFromMetaActions(row.actions, clicks), linkClicks,
spend: Math.round(n(row.spend) * 100) / 100, spend: Math.round(spend * 100) / 100,
}; };
} catch {
return null;
}
} }