fix: no mezclar banners/datos ECP ↔ De Tú a Tú Formación

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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-16 11:44:24 +02:00
parent 6763613ed4
commit c434fa5834
6 changed files with 392 additions and 63 deletions

View File

@@ -14,6 +14,7 @@ import {
buildGoogleCardsFromDaily, buildGoogleCardsFromDaily,
fetchGoogleCreativeCards, fetchGoogleCreativeCards,
} from '@/lib/ad-creatives-google'; } from '@/lib/ad-creatives-google';
import { filterRowsForTenantBrand } from '@/lib/tenant-brand-aliases';
export const dynamic = 'force-dynamic'; 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'; 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
const selectNoLc = const selectNoLc =
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'; 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
if (agency && ctx.viaView) {
const tid = encodeURIComponent(ctx.tenantId); 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) {
let d = await restGet<AdDailyRow[]>( let d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000` `/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 let q: any = await supabase
.from('ad_daily') .from('ad_daily')
.select(selectWithLc) .select(selectWithLc)
.eq('tenant_id', ctx.tenantId)
.gte('date', since) .gte('date', since)
.lte('date', until) .lte('date', until)
.order('date', { ascending: false }) .order('date', { ascending: false })
@@ -103,6 +109,7 @@ export async function GET(req: NextRequest) {
q = await supabase q = await supabase
.from('ad_daily') .from('ad_daily')
.select(selectNoLc) .select(selectNoLc)
.eq('tenant_id', ctx.tenantId)
.gte('date', since) .gte('date', since)
.lte('date', until) .lte('date', until)
.order('date', { ascending: false }) .order('date', { ascending: false })
@@ -110,6 +117,7 @@ export async function GET(req: NextRequest) {
} }
daily = (q.data as AdDailyRow[]) || []; daily = (q.data as AdDailyRow[]) || [];
} }
daily = filterRowsForTenantBrand(tenantSlug, daily);
const warnings: string[] = []; const warnings: string[] = [];
let metaAds: AdCreativeCard[] = []; 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 // Fast path: warehouse only — correct spend/clicks/leads from sync, no Graph wait
if (mode === 'fast') { if (mode === 'fast') {
if (platform === 'meta' || platform === 'all') { if (platform === 'meta' || platform === 'all') {
metaAds = buildMetaWarehouseCards(daily, since, until, 16); metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit = metaClickSplitFromDaily(daily, since, until); metaClickSplit = metaClickSplitFromDaily(daily, since, until);
} }
if (platform === 'google' || platform === 'all') { if (platform === 'google' || platform === 'all') {
@@ -152,6 +160,7 @@ export async function GET(req: NextRequest) {
since, since,
until, until,
limit: 16, limit: 16,
tenantSlug,
}), }),
fetchMetaAccountClickSplit({ fetchMetaAccountClickSplit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
@@ -161,7 +170,7 @@ export async function GET(req: NextRequest) {
]); ]);
metaAds = meta.ads.length metaAds = meta.ads.length
? meta.ads ? meta.ads
: buildMetaWarehouseCards(daily, since, until, 16); : buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit = metaClickSplit =
clickSplit || metaClickSplitFromDaily(daily, since, until); clickSplit || metaClickSplitFromDaily(daily, since, until);
if ( if (
@@ -171,7 +180,7 @@ export async function GET(req: NextRequest) {
warnings.push(meta.warning); warnings.push(meta.warning);
} }
} catch { } catch {
metaAds = buildMetaWarehouseCards(daily, since, until, 16); metaAds = buildMetaWarehouseCards(daily, since, until, 16, tenantSlug);
metaClickSplit = metaClickSplitFromDaily(daily, since, until); metaClickSplit = metaClickSplitFromDaily(daily, since, until);
} }
} }

View File

@@ -3,6 +3,10 @@ import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
import {
isForeignSiblingAccountId,
isForeignSiblingBrandText,
} from '@/lib/tenant-brand-aliases';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const maxDuration = 90; export const maxDuration = 90;
@@ -66,18 +70,39 @@ async function cacheSocialSnapshot(
); );
} }
function readSocialCache(integrations: Integ[]): { function readSocialCache(
integrations: Integ[],
tenantSlug?: string | null
): {
facebook?: unknown; facebook?: unknown;
instagram?: unknown; instagram?: unknown;
at?: string; at?: string;
} { } {
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {}; const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
for (const i of integrations) { 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 cache = i.meta?.social_cache;
const at = typeof i.meta?.social_cached_at === 'string' const at = typeof i.meta?.social_cached_at === 'string'
? i.meta.social_cached_at ? i.meta.social_cached_at
: i.last_sync_at || undefined; : i.last_sync_at || undefined;
if (!cache) continue; 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 === 'facebook') out.facebook = cache;
if (i.provider === 'instagram') out.instagram = cache; if (i.provider === 'instagram') out.instagram = cache;
if (at && (!out.at || at > out.at)) out.at = at; 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', 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 def = defaultRange();
const sinceParam = req.nextUrl.searchParams.get('since') || ''; const sinceParam = req.nextUrl.searchParams.get('since') || '';
@@ -839,20 +868,24 @@ export async function GET(req: NextRequest) {
i.provider === 'facebook' && i.provider === 'facebook' &&
i.status === 'connected' && i.status === 'connected' &&
i.external_account_id && 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( const igInteg = integrations.find(
(i) => (i) =>
i.provider === 'instagram' && i.provider === 'instagram' &&
i.status === 'connected' && i.status === 'connected' &&
i.external_account_id && 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) { if (!tokens.length) {
metrics.warning = metrics.warning =
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.'; '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.facebook) metrics.facebook = cached.facebook;
if (cached.instagram) metrics.instagram = cached.instagram; if (cached.instagram) metrics.instagram = cached.instagram;
if (cached.facebook || cached.instagram) { if (cached.facebook || cached.instagram) {
@@ -896,7 +929,7 @@ export async function GET(req: NextRequest) {
} }
} catch (e) { } catch (e) {
metrics.warning = e instanceof Error ? e.message : 'metrics error'; 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.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
if (cached.instagram && !metrics.instagram) if (cached.instagram && !metrics.instagram)
metrics.instagram = cached.instagram; metrics.instagram = cached.instagram;

View File

@@ -3,6 +3,11 @@ import { htmlTemplate } from './template';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { createClient as createSupabaseClient } from '@supabase/supabase-js'; import { createClient as createSupabaseClient } from '@supabase/supabase-js';
import {
filterRowsForTenantBrand,
isForeignSiblingAccountId,
isForeignSiblingBrandText,
} from '@/lib/tenant-brand-aliases';
export async function GET() { export async function GET() {
const supabase = await createClient(); 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 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') .from('ad_daily')
.select('*') .select('*')
.eq('tenant_id', tenant_id) .eq('tenant_id', tenant_id)
.order('date', { ascending: true }); .order('date', { ascending: true });
const adDaily = filterRowsForTenantBrand(tenantSlug, adDailyRaw || []);
const { data: orgDaily } = await adminSupabase const { data: orgDaily } = await adminSupabase
.from('organic_daily') .from('organic_daily')
@@ -45,9 +58,36 @@ export async function GET() {
.from('integrations') .from('integrations')
.select('*') .select('*')
.eq('tenant_id', tenant_id); .eq('tenant_id', tenant_id);
const igIntegration = integrations?.find((i: any) => {
const igIntegration = integrations?.find((i: any) => i.provider === 'instagram'); if (i.provider !== 'instagram' || i.status !== 'connected') return false;
const fbIntegration = integrations?.find((i: any) => i.provider === 'facebook'); 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 igCache = igIntegration?.meta?.social_cache;
const fbCache = fbIntegration?.meta?.social_cache; const fbCache = fbIntegration?.meta?.social_cache;

View File

@@ -3,6 +3,11 @@
import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads'; 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'; const META_GRAPH = 'https://graph.facebook.com/v21.0';
@@ -371,9 +376,13 @@ export function buildMetaWarehouseCards(
dailyRows: Parameters<typeof aggregateDailyByAd>[0], dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string, since: string,
until: string, until: string,
limit = 16 limit = 16,
tenantSlug?: string | null
): AdCreativeCard[] { ): AdCreativeCard[] {
return metaCardsFromDaily(dailyRows, since, until, limit); const rows = tenantSlug
? filterRowsForTenantBrand(tenantSlug, dailyRows)
: dailyRows;
return metaCardsFromDaily(rows, since, until, limit);
} }
function metaCardsFromDaily( function metaCardsFromDaily(
@@ -411,35 +420,89 @@ function metaCardsFromDaily(
}); });
} }
async function resolveTenantSlug(tenantId: string): Promise<string | null> {
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<string[]> { async function resolveMetaAccountIds(tenantId: string): Promise<string[]> {
const headers = serviceHeaders(); const headers = serviceHeaders();
const base = supabaseUrl(); const base = supabaseUrl();
if (!headers || !base) return []; if (!headers || !base) return [];
const r = await fetch( const [slug, r] = await Promise.all([
resolveTenantSlug(tenantId),
fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(
tenantId tenantId
)}&provider=eq.meta_ads&select=external_account_id,status,meta&limit=1`, )}&provider=eq.meta_ads&select=external_account_id,status,enabled,meta&limit=1`,
{ headers, cache: 'no-store' } { headers, cache: 'no-store' }
); ),
]);
if (!r.ok) return []; if (!r.ok) return [];
const rows = (await r.json()) as { const rows = (await r.json()) as {
external_account_id?: string | null; external_account_id?: string | null;
status?: string | null;
enabled?: boolean | null;
meta?: { meta?: {
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; account_id?: string;
alternate_accounts?: { external_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; } | null;
}[]; }[];
const row = rows[0]; 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 out: string[] = [];
const primary = row?.external_account_id?.trim(); const primary = row.external_account_id?.trim() || '';
if (primary) out.push(primary); if (accept(primary, row.meta?.account_name, row.meta?.brand)) out.push(primary);
for (const x of row?.meta?.extra_ad_accounts || []) {
const id = (x.account_id || x.external_account_id || '').trim(); // Never trust meta.account_id when it differs from the linked external id
if (id) out.push(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(); const id = (x.external_account_id || '').trim();
if (id) out.push(id); if (accept(id, x.name)) out.push(id);
} }
return [...new Set(out)]; return [...new Set(out)];
} }
@@ -763,12 +826,18 @@ export async function fetchMetaCreativeCards(opts: {
since: string; since: string;
until: string; until: string;
limit?: number; limit?: number;
tenantSlug?: string | null;
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> { }): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const slug = opts.tenantSlug ?? (await resolveTenantSlug(opts.tenantId));
const safeDaily = filterRowsForTenantBrand(slug, opts.dailyRows);
const warehouse = () => const warehouse = () =>
metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16); metaCardsFromDaily(safeDaily, opts.since, opts.until, opts.limit ?? 16);
try { try {
return await fetchMetaCreativeCardsInner(opts, warehouse); return await fetchMetaCreativeCardsInner(
{ ...opts, tenantSlug: slug, dailyRows: safeDaily },
warehouse
);
} catch { } catch {
const ads = warehouse(); const ads = warehouse();
return ads.length return ads.length
@@ -784,6 +853,7 @@ async function fetchMetaCreativeCardsInner(
since: string; since: string;
until: string; until: string;
limit?: number; limit?: number;
tenantSlug?: string | null;
}, },
warehouse: () => AdCreativeCard[] warehouse: () => AdCreativeCard[]
): Promise<{ ads: AdCreativeCard[]; warning?: string }> { ): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
@@ -803,6 +873,9 @@ async function fetchMetaCreativeCardsInner(
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
} }
const account = accounts[0]; const account = accounts[0];
const accountNorms = new Set(
accounts.map((a) => a.replace(/^act_/, ''))
);
const limit = opts.limit ?? 16; const limit = opts.limit ?? 16;
let targets: { let targets: {
@@ -825,22 +898,59 @@ async function fetchMetaCreativeCardsInner(
opts.until, opts.until,
limit - targets.length limit - targets.length
); );
targets.push(...part); targets.push(
...part.filter(
(p) =>
!isForeignSiblingBrandText(
opts.tenantSlug,
p.adName,
p.campaignName
)
)
);
} 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.
// 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) { if (!targets.length) {
const campaigns = aggregateDailyByAd( const campaigns = aggregateDailyByAd(
opts.dailyRows, opts.dailyRows,
'meta', 'meta',
opts.since, opts.since,
opts.until opts.until
).slice(0, 8); )
.filter(
(c) =>
!isForeignSiblingBrandText(opts.tenantSlug, c.adName, c.campaignName)
)
.slice(0, 8);
for (const camp of campaigns) { for (const camp of campaigns) {
try { 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<{ const raw = await graphGet<{
data?: { id?: string; name?: string; effective_status?: string }[]; data?: { id?: string; name?: string; effective_status?: string }[];
}>( }>(
@@ -863,12 +973,18 @@ async function fetchMetaCreativeCardsInner(
for (const ad of ads) { for (const ad of ads) {
const id = str(ad.id); const id = str(ad.id);
if (!id) continue; if (!id) continue;
targets.push({ const adName =
adId: id,
adName:
str(ad.name) || str(ad.name) ||
cleanMetaAdName(camp.adName) || cleanMetaAdName(camp.adName) ||
camp.campaignName, camp.campaignName;
if (
isForeignSiblingBrandText(opts.tenantSlug, adName, camp.campaignName)
) {
continue;
}
targets.push({
adId: id,
adName,
campaignName: camp.campaignName, campaignName: camp.campaignName,
spend: camp.spend / share, spend: camp.spend / share,
impressions: Math.round(camp.impressions / share), impressions: Math.round(camp.impressions / share),

View File

@@ -4,6 +4,11 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
import { leadFromMetaActions, linkClicksFromMetaActions, nMeta } from '@/lib/meta-leads'; import { leadFromMetaActions, linkClicksFromMetaActions, nMeta } from '@/lib/meta-leads';
import { resolveGoogleAdsAccessToken } from '@/lib/google-ads-auth'; 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 META_GRAPH = 'https://graph.facebook.com/v21.0';
const GADS_API = 'https://googleads.googleapis.com/v22'; const GADS_API = 'https://googleads.googleapis.com/v22';
@@ -22,26 +27,47 @@ type IntegrationRow = {
provider: string; provider: string;
external_account_id: string | null; external_account_id: string | null;
status?: string; status?: string;
enabled?: boolean | null;
meta?: { meta?: {
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; extra_ad_accounts?: {
alternate_accounts?: { external_account_id?: string }[]; account_id?: string;
external_account_id?: string;
name?: string;
}[];
alternate_accounts?: { external_account_id?: string; name?: string }[];
} | null; } | null;
}; };
function extraAccountIds(row: IntegrationRow | undefined): string[] { function extraAccountIds(
row: IntegrationRow | undefined,
tenantSlug?: string | null
): string[] {
if (!row) return []; if (!row) return [];
const out: string[] = []; const out: string[] = [];
for (const x of row.meta?.extra_ad_accounts || []) { for (const x of row.meta?.extra_ad_accounts || []) {
const id = x.account_id || x.external_account_id; 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 || []) { 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; 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 = { type AdDailyInsert = {
tenant_id: string; tenant_id: string;
@@ -535,7 +561,7 @@ export async function syncAdsForTenants(opts?: {
); );
const tRes = await fetch( 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' } { headers, cache: 'no-store' }
); );
if (!tRes.ok) throw new Error(await tRes.text()); if (!tRes.ok) throw new Error(await tRes.text());
@@ -543,7 +569,7 @@ export async function syncAdsForTenants(opts?: {
if (opts?.tenantId) { if (opts?.tenantId) {
// Explicit id may be archived satellite — still allow // Explicit id may be archived satellite — still allow
const all = await fetch( 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' } { headers, cache: 'no-store' }
); );
const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants; const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants;
@@ -551,7 +577,7 @@ export async function syncAdsForTenants(opts?: {
} }
const iRes = await fetch( 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' } { headers, cache: 'no-store' }
); );
if (!iRes.ok) throw new Error(await iRes.text()); 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 mine = integrations.filter((i) => i.tenant_id === tenant.id);
const rows: AdDailyInsert[] = []; const rows: AdDailyInsert[] = [];
const platforms: string[] = []; 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 = [ const metaIds = [
...(meta?.external_account_id ? [meta.external_account_id] : []), ...(meta?.external_account_id &&
...extraAccountIds(meta), !isForeignSiblingAccountId(slug, meta.external_account_id)
? [meta.external_account_id]
: []),
...extraAccountIds(meta, slug),
]; ];
for (const accountId of [...new Set(metaIds)]) { for (const accountId of [...new Set(metaIds)]) {
try { try {
const mrows = await fetchMetaAdDaily(accountId, tenant.id, days); const mrows = filterRowsForTenantBrand(
slug,
await fetchMetaAdDaily(accountId, tenant.id, days)
);
rows.push(...mrows); rows.push(...mrows);
result.metaRows += mrows.length; result.metaRows += mrows.length;
} catch (e) { } 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( const gads = mine.find(
(i) => i.provider === 'google_ads' && i.external_account_id (i) => i.provider === 'google_ads' && integrationUsable(i)
); );
const googleIds = [ const googleIds = [
...(gads?.external_account_id ? [gads.external_account_id] : []), ...(gads?.external_account_id &&
...extraAccountIds(gads), !isForeignSiblingAccountId(slug, gads.external_account_id)
? [gads.external_account_id]
: []),
...extraAccountIds(gads, slug),
]; ];
if (googleIds.length) { if (googleIds.length) {
if (!gToken) { if (!gToken) {
result.errors.push('Google Ads sin token OAuth'); result.errors.push('Google Ads sin token OAuth');
} else { } else {
let googleOk = false;
for (const customerId of [...new Set(googleIds)]) { for (const customerId of [...new Set(googleIds)]) {
try { try {
const grows = await fetchGoogleCampaignDaily( const grows = filterRowsForTenantBrand(
slug,
await fetchGoogleCampaignDaily(
customerId, customerId,
tenant.id, tenant.id,
days, days,
gToken gToken
)
); );
rows.push(...grows); rows.push(...grows);
result.googleRows += grows.length; result.googleRows += grows.length;
googleOk = true;
} catch (e) { } catch (e) {
result.errors.push( result.errors.push(
e instanceof Error ? e.message : 'google sync error' 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 { try {
await replaceDaily(tenant.id, platforms, rows, days); await replaceDaily(tenant.id, platforms, rows, days);
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days); result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days);

View File

@@ -64,3 +64,88 @@ export function brandSiblingNote(slug: string): string | null {
const sib = BRAND_SIBLINGS[canon as BrandCanonicalSlug]; const sib = BRAND_SIBLINGS[canon as BrandCanonicalSlug];
return `Marca hermana (separada): ${BRAND_DISPLAY_NAMES[sib]} (${sib}). No mezclar creatividades, Meta ni Drive.`; 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<BrandCanonicalSlug, string[]> = {
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<BrandCanonicalSlug, RegExp> = {
/** 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
)
);
}