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:
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
`${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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user