From c434fa5834d3dc4f7149abe0cf05a0d85f870265 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Fri, 16 Oct 2026 11:44:24 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20no=20mezclar=20banners/datos=20ECP=20?= =?UTF-8?q?=E2=86=94=20De=20T=C3=BA=20a=20T=C3=BA=20Formaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aísla marcas hermanas en creatives, sync y social cache para que cada cuenta solo muestre sus propias campañas y creatividades. Co-authored-by: Cursor --- src/app/api/ad-tracker/creatives/route.ts | 17 ++- src/app/api/social/route.ts | 43 +++++- src/app/dashboard/kpis/kpis/route.ts | 48 ++++++- src/lib/ad-creatives-meta.ts | 168 ++++++++++++++++++---- src/lib/ads-sync.ts | 94 ++++++++---- src/lib/tenant-brand-aliases.ts | 85 +++++++++++ 6 files changed, 392 insertions(+), 63 deletions(-) diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index 8233e23..5263d46 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -14,6 +14,7 @@ import { buildGoogleCardsFromDaily, fetchGoogleCreativeCards, } from '@/lib/ad-creatives-google'; +import { filterRowsForTenantBrand } from '@/lib/tenant-brand-aliases'; export const dynamic = 'force-dynamic'; @@ -80,8 +81,12 @@ export async function GET(req: NextRequest) { 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads'; const selectNoLc = 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'; + const tid = encodeURIComponent(ctx.tenantId); + const slugRes = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${tid}&select=slug&limit=1` + ); + const tenantSlug = slugRes.data?.[0]?.slug || null; if (agency && ctx.viaView) { - const tid = encodeURIComponent(ctx.tenantId); let d = await restGet( `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000` ); @@ -95,6 +100,7 @@ export async function GET(req: NextRequest) { let q: any = await supabase .from('ad_daily') .select(selectWithLc) + .eq('tenant_id', ctx.tenantId) .gte('date', since) .lte('date', until) .order('date', { ascending: false }) @@ -103,6 +109,7 @@ export async function GET(req: NextRequest) { q = await supabase .from('ad_daily') .select(selectNoLc) + .eq('tenant_id', ctx.tenantId) .gte('date', since) .lte('date', until) .order('date', { ascending: false }) @@ -110,6 +117,7 @@ export async function GET(req: NextRequest) { } daily = (q.data as AdDailyRow[]) || []; } + daily = filterRowsForTenantBrand(tenantSlug, daily); const warnings: string[] = []; let metaAds: AdCreativeCard[] = []; @@ -123,7 +131,7 @@ export async function GET(req: NextRequest) { // Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait if (mode === 'fast') { if (platform === 'meta' || platform === 'all') { - metaAds = buildMetaWarehouseCards(daily, since, until, 16); + metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug); metaClickSplit = metaClickSplitFromDaily(daily, since, until); } if (platform === 'google' || platform === 'all') { @@ -152,6 +160,7 @@ export async function GET(req: NextRequest) { since, until, limit: 16, + tenantSlug, }), fetchMetaAccountClickSplit({ tenantId: ctx.tenantId, @@ -161,7 +170,7 @@ export async function GET(req: NextRequest) { ]); metaAds = meta.ads.length ? meta.ads - : buildMetaWarehouseCards(daily, since, until, 16); + : buildMetaWarehouseCards(daily, since, until, 16, tenantSlug); metaClickSplit = clickSplit || metaClickSplitFromDaily(daily, since, until); if ( @@ -171,7 +180,7 @@ export async function GET(req: NextRequest) { warnings.push(meta.warning); } } catch { - metaAds = buildMetaWarehouseCards(daily, since, until, 16); + metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug); metaClickSplit = metaClickSplitFromDaily(daily, since, until); } } diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index c4d6383..c48616e 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -3,6 +3,10 @@ import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; +import { + isForeignSiblingAccountId, + isForeignSiblingBrandText, +} from '@/lib/tenant-brand-aliases'; export const dynamic = 'force-dynamic'; export const maxDuration = 90; @@ -66,18 +70,39 @@ async function cacheSocialSnapshot( ); } -function readSocialCache(integrations: Integ[]): { +function readSocialCache( + integrations: Integ[], + tenantSlug?: string | null +): { facebook?: unknown; instagram?: unknown; at?: string; } { const out: { facebook?: unknown; instagram?: unknown; at?: string } = {}; for (const i of integrations) { + if (i.status && i.status !== 'connected') continue; + if (isForeignSiblingAccountId(tenantSlug, i.external_account_id)) continue; const cache = i.meta?.social_cache; const at = typeof i.meta?.social_cached_at === 'string' ? i.meta.social_cached_at : i.last_sync_at || undefined; if (!cache) continue; + const cacheObj = cache as { + username?: string; + name?: string; + id?: string; + }; + if ( + isForeignSiblingBrandText( + tenantSlug, + cacheObj.username, + cacheObj.name, + typeof i.meta?.account_name === 'string' ? i.meta.account_name : null, + i.label + ) + ) { + continue; + } if (i.provider === 'facebook') out.facebook = cache; if (i.provider === 'instagram') out.instagram = cache; if (at && (!out.at || at > out.at)) out.at = at; @@ -803,6 +828,10 @@ export async function GET(req: NextRequest) { warning: 'Selecciona un cliente en Contexto', }); } + const slugRes = await restGet<{ slug?: string }[]>( + `/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug&limit=1` + ); + const tenantSlug = slugRes.data?.[0]?.slug || null; const def = defaultRange(); const sinceParam = req.nextUrl.searchParams.get('since') || ''; @@ -839,20 +868,24 @@ export async function GET(req: NextRequest) { i.provider === 'facebook' && i.status === 'connected' && i.external_account_id && - !i.external_account_id.startsWith('http') + !i.external_account_id.startsWith('http') && + !isForeignSiblingAccountId(tenantSlug, i.external_account_id) && + !isForeignSiblingBrandText(tenantSlug, i.label) ); const igInteg = integrations.find( (i) => i.provider === 'instagram' && i.status === 'connected' && i.external_account_id && - !i.external_account_id.startsWith('http') + !i.external_account_id.startsWith('http') && + !isForeignSiblingAccountId(tenantSlug, i.external_account_id) && + !isForeignSiblingBrandText(tenantSlug, i.label) ); if (!tokens.length) { metrics.warning = 'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.'; - const cached = readSocialCache(integrations); + const cached = readSocialCache(integrations, tenantSlug); if (cached.facebook) metrics.facebook = cached.facebook; if (cached.instagram) metrics.instagram = cached.instagram; if (cached.facebook || cached.instagram) { @@ -896,7 +929,7 @@ export async function GET(req: NextRequest) { } } catch (e) { metrics.warning = e instanceof Error ? e.message : 'metrics error'; - const cached = readSocialCache(integrations); + const cached = readSocialCache(integrations, tenantSlug); if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook; if (cached.instagram && !metrics.instagram) metrics.instagram = cached.instagram; diff --git a/src/app/dashboard/kpis/kpis/route.ts b/src/app/dashboard/kpis/kpis/route.ts index 30bd676..b97a741 100644 --- a/src/app/dashboard/kpis/kpis/route.ts +++ b/src/app/dashboard/kpis/kpis/route.ts @@ -3,6 +3,11 @@ import { htmlTemplate } from './template'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant } from '@/lib/tenant-context'; import { createClient as createSupabaseClient } from '@supabase/supabase-js'; +import { + filterRowsForTenantBrand, + isForeignSiblingAccountId, + isForeignSiblingBrandText, +} from '@/lib/tenant-brand-aliases'; export async function GET() { const supabase = await createClient(); @@ -23,11 +28,19 @@ export async function GET() { const adminSupabase = createSupabaseClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!); - const { data: adDaily } = await adminSupabase + const { data: tenantRow } = await adminSupabase + .from('tenants') + .select('slug') + .eq('id', tenant_id) + .maybeSingle(); + const tenantSlug = (tenantRow as { slug?: string } | null)?.slug || null; + + const { data: adDailyRaw } = await adminSupabase .from('ad_daily') .select('*') .eq('tenant_id', tenant_id) .order('date', { ascending: true }); + const adDaily = filterRowsForTenantBrand(tenantSlug, adDailyRaw || []); const { data: orgDaily } = await adminSupabase .from('organic_daily') @@ -45,9 +58,36 @@ export async function GET() { .from('integrations') .select('*') .eq('tenant_id', tenant_id); - - const igIntegration = integrations?.find((i: any) => i.provider === 'instagram'); - const fbIntegration = integrations?.find((i: any) => i.provider === 'facebook'); + const igIntegration = integrations?.find((i: any) => { + if (i.provider !== 'instagram' || i.status !== 'connected') return false; + if (isForeignSiblingAccountId(tenantSlug, i.external_account_id)) return false; + if ( + isForeignSiblingBrandText( + tenantSlug, + i.label, + i.meta?.account_name, + i.meta?.social_cache?.username, + i.meta?.social_cache?.name + ) + ) + return false; + return true; + }); + const fbIntegration = integrations?.find((i: any) => { + if (i.provider !== 'facebook' || i.status !== 'connected') return false; + if (isForeignSiblingAccountId(tenantSlug, i.external_account_id)) return false; + if ( + isForeignSiblingBrandText( + tenantSlug, + i.label, + i.meta?.account_name, + i.meta?.social_cache?.username, + i.meta?.social_cache?.name + ) + ) + return false; + return true; + }); const igCache = igIntegration?.meta?.social_cache; const fbCache = fbIntegration?.meta?.social_cache; diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index b4619ed..84e3971 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -3,6 +3,11 @@ import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads'; +import { + filterRowsForTenantBrand, + isForeignSiblingAccountId, + isForeignSiblingBrandText, +} from '@/lib/tenant-brand-aliases'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; @@ -371,9 +376,13 @@ export function buildMetaWarehouseCards( dailyRows: Parameters[0], since: string, until: string, - limit = 16 + limit = 16, + tenantSlug?: string | null ): AdCreativeCard[] { - return metaCardsFromDaily(dailyRows, since, until, limit); + const rows = tenantSlug + ? filterRowsForTenantBrand(tenantSlug, dailyRows) + : dailyRows; + return metaCardsFromDaily(rows, since, until, limit); } function metaCardsFromDaily( @@ -411,35 +420,89 @@ function metaCardsFromDaily( }); } +async function resolveTenantSlug(tenantId: string): Promise { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) return null; + const r = await fetch( + `${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=slug&limit=1`, + { headers, cache: 'no-store' } + ); + if (!r.ok) return null; + const rows = (await r.json()) as { slug?: string }[]; + return rows[0]?.slug || null; +} + async function resolveMetaAccountIds(tenantId: string): Promise { const headers = serviceHeaders(); const base = supabaseUrl(); 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,meta&limit=1`, - { headers, cache: 'no-store' } - ); + const [slug, r] = await Promise.all([ + resolveTenantSlug(tenantId), + fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( + tenantId + )}&provider=eq.meta_ads&select=external_account_id,status,enabled,meta&limit=1`, + { headers, cache: 'no-store' } + ), + ]); if (!r.ok) return []; const rows = (await r.json()) as { external_account_id?: string | null; + status?: string | null; + enabled?: boolean | null; meta?: { - extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; - alternate_accounts?: { external_account_id?: string }[]; + account_id?: string; + account_name?: string; + brand?: string; + extra_ad_accounts?: { + account_id?: string; + external_account_id?: string; + name?: string; + brand?: string; + }[]; + alternate_accounts?: { + external_account_id?: string; + name?: string; + }[]; } | null; }[]; const row = rows[0]; + if (!row) return []; + // Disconnected / disabled → do not pull live creatives from any account + if (row.status && row.status !== 'connected') return []; + if (row.enabled === false) return []; + + const accept = (id: string, ...labels: (string | null | undefined)[]) => { + if (!id) return false; + if (isForeignSiblingAccountId(slug, id)) return false; + if (isForeignSiblingBrandText(slug, ...labels)) return false; + return true; + }; + 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); + const primary = row.external_account_id?.trim() || ''; + if (accept(primary, row.meta?.account_name, row.meta?.brand)) out.push(primary); + + // Never trust meta.account_id when it differs from the linked external id + // (legacy ECP id left on DTATF after the brand split). + const metaAid = String(row.meta?.account_id || '').trim(); + if ( + metaAid && + metaAid !== primary && + metaAid.replace(/^act_/, '') !== primary.replace(/^act_/, '') && + accept(metaAid, row.meta?.account_name, row.meta?.brand) + ) { + /* intentionally skip — primary external_account_id is source of truth */ } - for (const x of row?.meta?.alternate_accounts || []) { + + for (const x of row.meta?.extra_ad_accounts || []) { + const id = (x.account_id || x.external_account_id || '').trim(); + if (accept(id, x.name, x.brand)) out.push(id); + } + for (const x of row.meta?.alternate_accounts || []) { const id = (x.external_account_id || '').trim(); - if (id) out.push(id); + if (accept(id, x.name)) out.push(id); } return [...new Set(out)]; } @@ -763,12 +826,18 @@ export async function fetchMetaCreativeCards(opts: { since: string; until: string; limit?: number; + tenantSlug?: string | null; }): Promise<{ ads: AdCreativeCard[]; warning?: string }> { + const slug = opts.tenantSlug ?? (await resolveTenantSlug(opts.tenantId)); + const safeDaily = filterRowsForTenantBrand(slug, opts.dailyRows); const warehouse = () => - metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16); + metaCardsFromDaily(safeDaily, opts.since, opts.until, opts.limit ?? 16); try { - return await fetchMetaCreativeCardsInner(opts, warehouse); + return await fetchMetaCreativeCardsInner( + { ...opts, tenantSlug: slug, dailyRows: safeDaily }, + warehouse + ); } catch { const ads = warehouse(); return ads.length @@ -784,6 +853,7 @@ async function fetchMetaCreativeCardsInner( since: string; until: string; limit?: number; + tenantSlug?: string | null; }, warehouse: () => AdCreativeCard[] ): Promise<{ ads: AdCreativeCard[]; warning?: string }> { @@ -803,6 +873,9 @@ async function fetchMetaCreativeCardsInner( : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; } const account = accounts[0]; + const accountNorms = new Set( + accounts.map((a) => a.replace(/^act_/, '')) + ); const limit = opts.limit ?? 16; let targets: { @@ -825,22 +898,59 @@ async function fetchMetaCreativeCardsInner( opts.until, limit - targets.length ); - targets.push(...part); + targets.push( + ...part.filter( + (p) => + !isForeignSiblingBrandText( + opts.tenantSlug, + p.adName, + p.campaignName + ) + ) + ); } catch { /* 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. + // Only expand if the campaign belongs to THIS tenant's ad account(s) — + // otherwise a shared agency token would pull sibling-brand banners (ECP↔DTATF). if (!targets.length) { const campaigns = aggregateDailyByAd( opts.dailyRows, 'meta', opts.since, opts.until - ).slice(0, 8); + ) + .filter( + (c) => + !isForeignSiblingBrandText(opts.tenantSlug, c.adName, c.campaignName) + ) + .slice(0, 8); for (const camp of campaigns) { try { + const campMeta = await graphGet<{ + id?: string; + name?: string; + account_id?: string; + }>(`${camp.adId}`, { fields: 'id,name,account_id' }, token); + const campAccount = String(campMeta.account_id || '').replace( + /^act_/, + '' + ); + if (campAccount && !accountNorms.has(campAccount)) { + continue; + } + if ( + isForeignSiblingBrandText( + opts.tenantSlug, + campMeta.name, + camp.campaignName + ) + ) { + continue; + } const raw = await graphGet<{ data?: { id?: string; name?: string; effective_status?: string }[]; }>( @@ -863,12 +973,18 @@ async function fetchMetaCreativeCardsInner( for (const ad of ads) { const id = str(ad.id); if (!id) continue; + const adName = + str(ad.name) || + cleanMetaAdName(camp.adName) || + camp.campaignName; + if ( + isForeignSiblingBrandText(opts.tenantSlug, adName, camp.campaignName) + ) { + continue; + } targets.push({ adId: id, - adName: - str(ad.name) || - cleanMetaAdName(camp.adName) || - camp.campaignName, + adName, campaignName: camp.campaignName, spend: camp.spend / share, impressions: Math.round(camp.impressions / share), diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts index bf82c60..696c6ed 100644 --- a/src/lib/ads-sync.ts +++ b/src/lib/ads-sync.ts @@ -4,6 +4,11 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; import { leadFromMetaActions, linkClicksFromMetaActions, nMeta } from '@/lib/meta-leads'; import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth'; +import { + filterRowsForTenantBrand, + isForeignSiblingAccountId, + isForeignSiblingBrandText, +} from '@/lib/tenant-brand-aliases'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; const GADS_API = 'https://googleads.googleapis.com/v22'; @@ -22,26 +27,47 @@ type IntegrationRow = { provider: string; external_account_id: string | null; status?: string; + enabled?: boolean | null; meta?: { - extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; - alternate_accounts?: { external_account_id?: string }[]; + extra_ad_accounts?: { + account_id?: string; + external_account_id?: string; + name?: string; + }[]; + alternate_accounts?: { external_account_id?: string; name?: string }[]; } | null; }; -function extraAccountIds(row: IntegrationRow | undefined): string[] { +function extraAccountIds( + row: IntegrationRow | undefined, + tenantSlug?: string | null +): string[] { if (!row) return []; const out: string[] = []; for (const x of row.meta?.extra_ad_accounts || []) { const id = x.account_id || x.external_account_id; - if (id) out.push(id); + if (!id) continue; + if (isForeignSiblingAccountId(tenantSlug, id)) continue; + if (isForeignSiblingBrandText(tenantSlug, (x as { name?: string }).name)) + continue; + out.push(id); } for (const x of row.meta?.alternate_accounts || []) { - if (x.external_account_id) out.push(x.external_account_id); + if (!x.external_account_id) continue; + if (isForeignSiblingAccountId(tenantSlug, x.external_account_id)) continue; + out.push(x.external_account_id); } return out; } -type TenantRow = { id: string; name: string }; +function integrationUsable(row: IntegrationRow | undefined): boolean { + if (!row?.external_account_id) return false; + if (row.enabled === false) return false; + if (row.status && row.status !== 'connected') return false; + return true; +} + +type TenantRow = { id: string; name: string; slug?: string }; type AdDailyInsert = { tenant_id: string; @@ -535,7 +561,7 @@ export async function syncAdsForTenants(opts?: { ); const tRes = await fetch( - `${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`, + `${base}/rest/v1/tenants?select=id,name,slug&agency_followed=neq.false&order=name.asc`, { headers, cache: 'no-store' } ); if (!tRes.ok) throw new Error(await tRes.text()); @@ -543,7 +569,7 @@ export async function syncAdsForTenants(opts?: { if (opts?.tenantId) { // Explicit id may be archived satellite — still allow const all = await fetch( - `${base}/rest/v1/tenants?select=id,name&order=name.asc`, + `${base}/rest/v1/tenants?select=id,name,slug&order=name.asc`, { headers, cache: 'no-store' } ); const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants; @@ -551,7 +577,7 @@ export async function syncAdsForTenants(opts?: { } const iRes = await fetch( - `${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`, + `${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,enabled,meta&external_account_id=not.is.null`, { headers, cache: 'no-store' } ); if (!iRes.ok) throw new Error(await iRes.text()); @@ -578,15 +604,24 @@ export async function syncAdsForTenants(opts?: { const mine = integrations.filter((i) => i.tenant_id === tenant.id); const rows: AdDailyInsert[] = []; const platforms: string[] = []; + const slug = tenant.slug || null; - const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id); + const meta = mine.find( + (i) => i.provider === 'meta_ads' && integrationUsable(i) + ); const metaIds = [ - ...(meta?.external_account_id ? [meta.external_account_id] : []), - ...extraAccountIds(meta), + ...(meta?.external_account_id && + !isForeignSiblingAccountId(slug, meta.external_account_id) + ? [meta.external_account_id] + : []), + ...extraAccountIds(meta, slug), ]; for (const accountId of [...new Set(metaIds)]) { try { - const mrows = await fetchMetaAdDaily(accountId, tenant.id, days); + const mrows = filterRowsForTenantBrand( + slug, + await fetchMetaAdDaily(accountId, tenant.id, days) + ); rows.push(...mrows); result.metaRows += mrows.length; } catch (e) { @@ -595,40 +630,51 @@ export async function syncAdsForTenants(opts?: { ); } } - if (result.metaRows > 0) platforms.push('meta'); + // Always refresh the meta window when the account is linked (even 0 rows), + // so stale sibling-brand rows cannot linger after a split. + if (metaIds.length) platforms.push('meta'); const gads = mine.find( - (i) => i.provider === 'google_ads' && i.external_account_id + (i) => i.provider === 'google_ads' && integrationUsable(i) ); const googleIds = [ - ...(gads?.external_account_id ? [gads.external_account_id] : []), - ...extraAccountIds(gads), + ...(gads?.external_account_id && + !isForeignSiblingAccountId(slug, gads.external_account_id) + ? [gads.external_account_id] + : []), + ...extraAccountIds(gads, slug), ]; if (googleIds.length) { if (!gToken) { result.errors.push('Google Ads sin token OAuth'); } else { + let googleOk = false; for (const customerId of [...new Set(googleIds)]) { try { - const grows = await fetchGoogleCampaignDaily( - customerId, - tenant.id, - days, - gToken + const grows = filterRowsForTenantBrand( + slug, + await fetchGoogleCampaignDaily( + customerId, + tenant.id, + days, + gToken + ) ); rows.push(...grows); result.googleRows += grows.length; + googleOk = true; } catch (e) { result.errors.push( e instanceof Error ? e.message : 'google sync error' ); } } + // Refresh window on success (incl. 0 rows) so sibling contamination is purged. + if (googleOk) platforms.push('google'); } } - if (result.googleRows > 0) platforms.push('google'); - if (rows.length && platforms.length) { + if (platforms.length) { try { await replaceDaily(tenant.id, platforms, rows, days); result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days); diff --git a/src/lib/tenant-brand-aliases.ts b/src/lib/tenant-brand-aliases.ts index b17ac12..1d7c9db 100644 --- a/src/lib/tenant-brand-aliases.ts +++ b/src/lib/tenant-brand-aliases.ts @@ -64,3 +64,88 @@ export function brandSiblingNote(slug: string): string | null { const sib = BRAND_SIBLINGS[canon as BrandCanonicalSlug]; return `Marca hermana (separada): ${BRAND_DISPLAY_NAMES[sib]} (${sib}). No mezclar creatividades, Meta ni Drive.`; } + +/** Meta ad account / page / IG ids that belong to a sibling brand — never sync or show under the other. */ +export const BRAND_OWN_META_IDS: Record = { + ecp: [ + 'act_1320801442642004', + '1320801442642004', + '111175967913754', + '17841453624785447', + ], + dtatf: [ + 'act_413393941247349', + '413393941247349', + '487314041420934', + '17841405596986691', + ], + tyd: [], + tsp: [], +}; + +/** Campaign / ad / account text that belongs to the sibling — reject under this tenant. */ +const SIBLING_TEXT_RE: Record = { + /** Under DTATF, reject ECP creatives/campaigns (incl. ECP_ prefixes) */ + dtatf: + /(?:^|[^a-z0-9])ecp(?:[^a-z0-9]|$)|escuela\s*ecp|@escuelaecp|cuidadores?\s*profesionales?|escuela\s*de\s*cuidadores/i, + /** Under ECP, reject DTATF creatives/campaigns */ + ecp: /(?:^|[^a-z0-9])dtatf(?:[^a-z0-9]|$)|detuatu|de\s*t[uú]\s*a\s*t[uú]|@detuatuformacion|detuatuformacion/i, + tyd: /(?:^|[^a-z0-9])tsp(?:[^a-z0-9]|$)|scholar|talento\s*scholar/i, + tsp: /(?:^|[^a-z0-9])tyd(?:[^a-z0-9]|$)|talento\s*y\s*deporte|@talentoydeporte/i, +}; + +export function brandSiblingSlug(slug: string | null | undefined): BrandCanonicalSlug | null { + const canon = resolveBrandSlug(slug); + if (!(canon in BRAND_SIBLINGS)) return null; + return BRAND_SIBLINGS[canon as BrandCanonicalSlug]; +} + +/** True if text clearly belongs to the sibling brand of `tenantSlug`. */ +export function isForeignSiblingBrandText( + tenantSlug: string | null | undefined, + ...parts: (string | null | undefined)[] +): boolean { + const canon = resolveBrandSlug(tenantSlug); + const re = SIBLING_TEXT_RE[canon as BrandCanonicalSlug]; + if (!re) return false; + const hay = parts.filter(Boolean).join(' '); + if (!hay.trim()) return false; + return re.test(hay); +} + +/** True if an external Meta id belongs to the sibling brand. */ +export function isForeignSiblingAccountId( + tenantSlug: string | null | undefined, + accountId: string | null | undefined +): boolean { + const sib = brandSiblingSlug(tenantSlug); + if (!sib || !accountId) return false; + const raw = String(accountId).trim(); + const norm = raw.replace(/^act_/, ''); + const forbidden = BRAND_OWN_META_IDS[sib] || []; + return forbidden.some((id) => id === raw || id === norm || id.replace(/^act_/, '') === norm); +} + +/** Drop ad_daily-like rows whose campaign/ad names belong to the sibling brand. */ +export function filterRowsForTenantBrand< + T extends { + campaign_name?: string | null; + ad_name?: string | null; + campaign_id?: string | null; + ad_id?: string | null; + }, +>(tenantSlug: string | null | undefined, rows: T[]): T[] { + if (!tenantSlug || !rows.length) return rows; + const canon = resolveBrandSlug(tenantSlug); + if (!(canon in SIBLING_TEXT_RE)) return rows; + return rows.filter( + (r) => + !isForeignSiblingBrandText( + tenantSlug, + r.campaign_name, + r.ad_name, + r.campaign_id, + r.ad_id + ) + ); +}