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:
@@ -13,7 +13,15 @@ type Integ = {
|
||||
error_message?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
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 };
|
||||
@@ -354,6 +362,18 @@ export default function ClientIntegrationsPanel({
|
||||
TikTok Ads. OAuth live y publicación: siguiente fase.
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -394,7 +394,18 @@ export default function WindsorAiSection({
|
||||
},
|
||||
{
|
||||
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',
|
||||
type: 'Instagram & Facebook Ads',
|
||||
},
|
||||
|
||||
@@ -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 base = supabaseUrl();
|
||||
if (!headers || !base) return null;
|
||||
if (!headers || !base) return [];
|
||||
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' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
const rows = (await r.json()) as { external_account_id?: string | null }[];
|
||||
const id = rows[0]?.external_account_id?.trim();
|
||||
return id || null;
|
||||
if (!r.ok) return [];
|
||||
const rows = (await r.json()) as {
|
||||
external_account_id?: string | 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(
|
||||
@@ -771,13 +795,14 @@ async function fetchMetaCreativeCardsInner(
|
||||
: { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
|
||||
}
|
||||
|
||||
const account = await resolveMetaAccountId(opts.tenantId);
|
||||
if (!account) {
|
||||
const accounts = await resolveMetaAccountIds(opts.tenantId);
|
||||
if (!accounts.length) {
|
||||
const ads = warehouse();
|
||||
return ads.length
|
||||
? { ads }
|
||||
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
|
||||
}
|
||||
const account = accounts[0];
|
||||
|
||||
const limit = opts.limit ?? 16;
|
||||
let targets: {
|
||||
@@ -790,17 +815,21 @@ async function fetchMetaCreativeCardsInner(
|
||||
conversions: number;
|
||||
}[] = [];
|
||||
|
||||
for (const actId of accounts) {
|
||||
if (targets.length >= limit) break;
|
||||
try {
|
||||
targets = await fetchMetaAdLevelInsights(
|
||||
account,
|
||||
const part = await fetchMetaAdLevelInsights(
|
||||
actId,
|
||||
token,
|
||||
opts.since,
|
||||
opts.until,
|
||||
limit
|
||||
limit - targets.length
|
||||
);
|
||||
targets.push(...part);
|
||||
} catch {
|
||||
/* fall through — never surface ad-level Graph errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: expand campaigns from ad_daily → ads under each campaign
|
||||
if (!targets.length) {
|
||||
@@ -914,10 +943,15 @@ export async function fetchMetaAccountClickSplit(opts: {
|
||||
}): Promise<{ clicks: number; linkClicks: number; spend: number } | null> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) return null;
|
||||
const account = await resolveMetaAccountId(opts.tenantId);
|
||||
if (!account) return null;
|
||||
const act = account.startsWith('act_') ? account : `act_${account}`;
|
||||
const accounts = await resolveMetaAccountIds(opts.tenantId);
|
||||
if (!accounts.length) return null;
|
||||
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 {
|
||||
const raw = await graphGet<{
|
||||
data?: {
|
||||
@@ -937,14 +971,20 @@ export async function fetchMetaAccountClickSplit(opts: {
|
||||
token
|
||||
);
|
||||
const row = raw.data?.[0];
|
||||
if (!row) return null;
|
||||
const clicks = n(row.clicks);
|
||||
if (!row) continue;
|
||||
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 {
|
||||
clicks,
|
||||
linkClicks: linkClicksFromMetaActions(row.actions, clicks),
|
||||
spend: Math.round(n(row.spend) * 100) / 100,
|
||||
linkClicks,
|
||||
spend: Math.round(spend * 100) / 100,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user