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,
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';
if (agency && ctx.viaView) {
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[]>(
`/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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<typeof aggregateDailyByAd>[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<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[]> {
const headers = serviceHeaders();
const base = supabaseUrl();
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(
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' }
);
),
]);
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;
targets.push({
adId: id,
adName:
const adName =
str(ad.name) ||
cleanMetaAdName(camp.adName) ||
camp.campaignName,
camp.campaignName;
if (
isForeignSiblingBrandText(opts.tenantSlug, adName, camp.campaignName)
) {
continue;
}
targets.push({
adId: id,
adName,
campaignName: camp.campaignName,
spend: camp.spend / 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 { 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(
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);

View File

@@ -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<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
)
);
}