Meta cards use a 9:16 frame (carousel 1:1 then 4:5). Google gallery balances Search/PMax/Display and surfaces PMax multi-format thumbs. Co-authored-by: Cursor <cursoragent@cursor.com>
947 lines
27 KiB
TypeScript
947 lines
27 KiB
TypeScript
/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */
|
|
|
|
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
|
import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads';
|
|
|
|
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
|
|
|
const CREATIVE_FIELDS = [
|
|
'id',
|
|
'name',
|
|
'title',
|
|
'body',
|
|
'call_to_action_type',
|
|
'link_url',
|
|
'thumbnail_url',
|
|
'image_url',
|
|
'image_hash',
|
|
'video_id',
|
|
'object_story_spec',
|
|
'asset_feed_spec',
|
|
'effective_object_story_id',
|
|
].join(',');
|
|
|
|
export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown';
|
|
|
|
export type CreativeAsset = {
|
|
kind: 'image' | 'video' | 'logo' | 'youtube';
|
|
url: string;
|
|
label?: string;
|
|
aspect?: CreativeAspect;
|
|
youtubeId?: string;
|
|
title?: string;
|
|
};
|
|
|
|
/** Google Ads channel bucket for gallery grouping. */
|
|
export type GoogleCreativeChannel =
|
|
| 'search_brand'
|
|
| 'search_generic'
|
|
| 'pmax'
|
|
| 'display'
|
|
| 'other';
|
|
|
|
export type AdCreativeCard = {
|
|
platform: 'meta' | 'google';
|
|
adId: string;
|
|
adName: string;
|
|
campaignName: string;
|
|
adsetName?: string;
|
|
spend: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
conversions: number;
|
|
cpl: number | null;
|
|
mediaType: string;
|
|
/** Google: search_brand | search_generic | pmax | display | other */
|
|
channel?: GoogleCreativeChannel;
|
|
/** Best preview URL (prefer vertical / portrait). */
|
|
thumbnailUrl: string;
|
|
imageUrl: string;
|
|
/** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */
|
|
headline: string;
|
|
primaryText: string;
|
|
/** Titulares (Meta titles / Google headlines), máx. 5. */
|
|
headlines: string[];
|
|
/** Textos principales (Meta bodies), máx. 5. */
|
|
bodies: string[];
|
|
/** Descripción del enlace (Meta descriptions), suele ser 1. */
|
|
descriptions: string[];
|
|
longHeadlines?: string[];
|
|
assets: CreativeAsset[];
|
|
linkUrl?: string;
|
|
cta?: string;
|
|
businessName?: string;
|
|
status?: string;
|
|
youtubeId?: string;
|
|
};
|
|
|
|
type DailyAgg = {
|
|
adId: string;
|
|
adName: string;
|
|
campaignName: string;
|
|
spend: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
conversions: number;
|
|
};
|
|
|
|
function n(v: unknown): number {
|
|
const x = Number(v);
|
|
return Number.isFinite(x) ? x : 0;
|
|
}
|
|
|
|
function str(v: unknown): string {
|
|
return v == null ? '' : String(v).trim();
|
|
}
|
|
|
|
function uniq(arr: string[]): string[] {
|
|
return [...new Set(arr.filter(Boolean))];
|
|
}
|
|
|
|
export function inferCreativeAspect(
|
|
label?: string,
|
|
width?: number,
|
|
height?: number
|
|
): CreativeAspect {
|
|
const L = (label || '').toLowerCase();
|
|
// Dimensiones primero: labels tipo "video" no deben forzar landscape.
|
|
if (width && height && width > 0 && height > 0) {
|
|
const r = width / height;
|
|
if (r <= 0.62) return '9:16';
|
|
if (r <= 0.9) return '4:5';
|
|
if (r >= 1.5) return '16:9';
|
|
if (Math.abs(r - 1) < 0.12) return '1:1';
|
|
}
|
|
if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16';
|
|
if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5';
|
|
if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|youtube/.test(L)) {
|
|
return '16:9';
|
|
}
|
|
if (/1\s*:?\s*1|square/.test(L)) return '1:1';
|
|
return 'unknown';
|
|
}
|
|
|
|
function detectMediaType(creative: Record<string, unknown>): string {
|
|
if (creative.video_id) return 'video';
|
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
|
if (feed?.videos || feed?.video_ids) return 'video';
|
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
|
const link = oss?.link_data as Record<string, unknown> | undefined;
|
|
if (Array.isArray(link?.child_attachments) && (link!.child_attachments as unknown[]).length > 1) {
|
|
return 'carousel';
|
|
}
|
|
if (creative.image_url || creative.image_hash || creative.thumbnail_url) return 'image';
|
|
return 'unknown';
|
|
}
|
|
|
|
type Extracted = {
|
|
headlines: string[];
|
|
bodies: string[];
|
|
descriptions: string[];
|
|
assets: CreativeAsset[];
|
|
linkUrl: string;
|
|
cta: string;
|
|
hashes: string[];
|
|
};
|
|
|
|
function extractCreativePack(creative: Record<string, unknown>): Extracted {
|
|
const headlines: string[] = [];
|
|
const bodies: string[] = [];
|
|
const descriptions: string[] = [];
|
|
const assets: CreativeAsset[] = [];
|
|
const hashes: string[] = [];
|
|
const pushHash = (h: unknown) => {
|
|
const s = str(h);
|
|
if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s);
|
|
};
|
|
const pushImg = (
|
|
url: unknown,
|
|
label?: string,
|
|
width?: number,
|
|
height?: number
|
|
) => {
|
|
const u = str(url);
|
|
if (!/^https?:\/\//i.test(u)) return;
|
|
if (assets.some((a) => a.url === u)) return;
|
|
assets.push({
|
|
kind: 'image',
|
|
url: u,
|
|
label,
|
|
aspect: inferCreativeAspect(label, width, height),
|
|
});
|
|
};
|
|
|
|
const oss = creative.object_story_spec as Record<string, unknown> | undefined;
|
|
const link = oss?.link_data as Record<string, unknown> | undefined;
|
|
const video = oss?.video_data as Record<string, unknown> | undefined;
|
|
const photo = oss?.photo_data as Record<string, unknown> | undefined;
|
|
|
|
// Story (anuncio publicado): 1 titular + texto + descripción de enlace.
|
|
if (link?.name) headlines.push(str(link.name));
|
|
if (link?.message) bodies.push(str(link.message));
|
|
if (link?.description) descriptions.push(str(link.description));
|
|
pushImg(link?.picture, '4:5 story', 1080, 1350);
|
|
pushImg(link?.image_url, '4:5 story', 1080, 1350);
|
|
pushHash(link?.image_hash);
|
|
|
|
if (video?.title) headlines.push(str(video.title));
|
|
if (video?.message) bodies.push(str(video.message));
|
|
if (video?.link_description) descriptions.push(str(video.link_description));
|
|
pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920);
|
|
pushHash(video?.image_hash);
|
|
|
|
pushImg(photo?.url, 'photo 4:5', 1080, 1350);
|
|
pushHash(photo?.image_hash);
|
|
|
|
if (creative.title) headlines.push(str(creative.title));
|
|
if (creative.body) bodies.push(str(creative.body));
|
|
pushImg(creative.image_url, 'image 4:5', 1080, 1350);
|
|
pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920);
|
|
pushHash(creative.image_hash);
|
|
|
|
if (Array.isArray(link?.child_attachments)) {
|
|
(link!.child_attachments as Record<string, unknown>[]).forEach((c, i) => {
|
|
// Carrusel: preferimos 1:1; si el asset es más alto, se reclasifica por ratio.
|
|
pushImg(c.picture || c.image_url, `carousel-${i + 1} 1:1`, 1080, 1080);
|
|
pushHash(c.image_hash);
|
|
if (c.name) headlines.push(str(c.name));
|
|
if (c.message) bodies.push(str(c.message));
|
|
if (c.description) descriptions.push(str(c.description));
|
|
});
|
|
}
|
|
|
|
// Dynamic creative / Advantage+: hasta 5 titles, 5 bodies, descriptions.
|
|
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
|
|
if (feed) {
|
|
for (const t of (feed.titles as { text?: string }[]) || []) {
|
|
if (t?.text) headlines.push(str(t.text));
|
|
}
|
|
for (const b of (feed.bodies as { text?: string }[]) || []) {
|
|
if (b?.text) bodies.push(str(b.text));
|
|
}
|
|
for (const d of (feed.descriptions as { text?: string }[]) || []) {
|
|
if (d?.text) descriptions.push(str(d.text));
|
|
}
|
|
for (const img of (feed.images as Record<string, unknown>[]) || []) {
|
|
const labels = Array.isArray(img.adlabels)
|
|
? (img.adlabels as { name?: string }[])
|
|
.map((l) => str(l.name))
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
: '';
|
|
pushImg(
|
|
img.url || img.permalink_url,
|
|
labels || 'feed',
|
|
n(img.width) || undefined,
|
|
n(img.height) || undefined
|
|
);
|
|
pushHash(img.hash);
|
|
}
|
|
for (const v of (feed.videos as Record<string, unknown>[]) || []) {
|
|
pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920);
|
|
if (v.video_id) {
|
|
assets.push({
|
|
kind: 'video',
|
|
url: str(v.thumbnail_url || v.picture),
|
|
label: 'video 9:16',
|
|
title: str(v.video_id),
|
|
aspect: '9:16',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const linkUrl =
|
|
str(creative.link_url) ||
|
|
str(link?.link) ||
|
|
str(video?.link) ||
|
|
'';
|
|
const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type;
|
|
const cta = str(creative.call_to_action_type) || str(ctaRaw);
|
|
|
|
return {
|
|
headlines: uniq(headlines).slice(0, 5),
|
|
bodies: uniq(bodies).slice(0, 5),
|
|
descriptions: uniq(descriptions).slice(0, 5),
|
|
assets,
|
|
linkUrl,
|
|
cta,
|
|
hashes: uniq(hashes),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Meta preview picker:
|
|
* - Default: vertical 9:16 (vídeo/imagen), then 4:5
|
|
* - Carousel: 1:1, then 4:5
|
|
*/
|
|
export function pickMetaPreviewAsset(
|
|
assets: CreativeAsset[],
|
|
mediaType: string,
|
|
fallbackUrl = ''
|
|
): { url: string; aspect: CreativeAspect } {
|
|
const withUrl = assets.filter((a) => a.url);
|
|
const aspectOf = (a: CreativeAsset) =>
|
|
a.aspect && a.aspect !== 'unknown'
|
|
? a.aspect
|
|
: inferCreativeAspect(a.label);
|
|
|
|
if (mediaType === 'carousel') {
|
|
const square = withUrl.find((a) => aspectOf(a) === '1:1');
|
|
if (square) return { url: square.url, aspect: '1:1' };
|
|
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
|
|
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
|
|
const carousel = withUrl.find((a) => /carousel/i.test(a.label || ''));
|
|
if (carousel) return { url: carousel.url, aspect: '1:1' };
|
|
if (withUrl[0]) return { url: withUrl[0].url, aspect: '1:1' };
|
|
return { url: fallbackUrl, aspect: '1:1' };
|
|
}
|
|
|
|
// Prefer video poster/frame labeled 9:16, then any 9:16 image, then 4:5
|
|
const videoNine = withUrl.find(
|
|
(a) =>
|
|
(a.kind === 'video' || /video|reel|story/i.test(a.label || '')) &&
|
|
aspectOf(a) === '9:16'
|
|
);
|
|
if (videoNine) return { url: videoNine.url, aspect: '9:16' };
|
|
|
|
const nine = withUrl.find((a) => aspectOf(a) === '9:16');
|
|
if (nine) return { url: nine.url, aspect: '9:16' };
|
|
|
|
const fourFive = withUrl.find((a) => aspectOf(a) === '4:5');
|
|
if (fourFive) return { url: fourFive.url, aspect: '4:5' };
|
|
|
|
const thumb = withUrl.find((a) =>
|
|
/thumbnail|video_poster|video_feed/i.test(a.label || '')
|
|
);
|
|
if (thumb) return { url: thumb.url, aspect: '9:16' };
|
|
|
|
if (withUrl[0]) {
|
|
return { url: withUrl[0].url, aspect: aspectOf(withUrl[0]) || '9:16' };
|
|
}
|
|
return { url: fallbackUrl, aspect: '9:16' };
|
|
}
|
|
|
|
async function sleep(ms: number) {
|
|
await new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
async function graphGet<T>(
|
|
path: string,
|
|
params: Record<string, string>,
|
|
token: string
|
|
): Promise<T> {
|
|
const q = new URLSearchParams({ ...params, access_token: token });
|
|
const url = `${META_GRAPH}/${path}?${q}`;
|
|
let lastMsg = 'fetch failed';
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
const r = await fetch(url, {
|
|
cache: 'no-store',
|
|
signal: AbortSignal.timeout(28_000),
|
|
});
|
|
if (r.ok) return (await r.json()) as T;
|
|
lastMsg = (await r.text()).slice(0, 220);
|
|
// Rate limit / transient
|
|
if (r.status === 429 || r.status >= 500) {
|
|
await sleep(500 * (attempt + 1));
|
|
continue;
|
|
}
|
|
break;
|
|
} catch (e) {
|
|
lastMsg = e instanceof Error ? e.message : String(e);
|
|
await sleep(400 * (attempt + 1));
|
|
}
|
|
}
|
|
throw new Error(`Meta ${path}: ${lastMsg}`);
|
|
}
|
|
|
|
function cleanMetaAdName(name: string): string {
|
|
if (/^__lc:/.test(name)) return '';
|
|
return name;
|
|
}
|
|
|
|
/** Campaign-level cards from warehouse — always available, never throws. */
|
|
export function buildMetaWarehouseCards(
|
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
|
since: string,
|
|
until: string,
|
|
limit = 16
|
|
): AdCreativeCard[] {
|
|
return metaCardsFromDaily(dailyRows, since, until, limit);
|
|
}
|
|
|
|
function metaCardsFromDaily(
|
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
|
|
since: string,
|
|
until: string,
|
|
limit: number
|
|
): AdCreativeCard[] {
|
|
return aggregateDailyByAd(dailyRows, 'meta', since, until)
|
|
.slice(0, limit)
|
|
.map((agg) => {
|
|
const adName =
|
|
cleanMetaAdName(agg.adName) || agg.campaignName || agg.adId;
|
|
return {
|
|
platform: 'meta' as const,
|
|
adId: agg.adId,
|
|
adName,
|
|
campaignName: agg.campaignName,
|
|
spend: Math.round(agg.spend * 100) / 100,
|
|
impressions: agg.impressions,
|
|
clicks: agg.clicks,
|
|
conversions: Math.round(agg.conversions * 100) / 100,
|
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
|
mediaType: 'campaign',
|
|
thumbnailUrl: '',
|
|
imageUrl: '',
|
|
headline: adName,
|
|
primaryText: agg.campaignName,
|
|
headlines: adName ? [adName] : [],
|
|
bodies: [],
|
|
descriptions: agg.campaignName ? [agg.campaignName] : [],
|
|
assets: [] as CreativeAsset[],
|
|
status: undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function resolveMetaAccountId(tenantId: string): Promise<string | null> {
|
|
const headers = serviceHeaders();
|
|
const base = supabaseUrl();
|
|
if (!headers || !base) return null;
|
|
const r = await fetch(
|
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.meta_ads&select=external_account_id,status&limit=1`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!r.ok) return null;
|
|
const rows = (await r.json()) as { external_account_id?: string | null }[];
|
|
const id = rows[0]?.external_account_id?.trim();
|
|
return id || null;
|
|
}
|
|
|
|
async function resolveImageHashes(
|
|
accountId: string,
|
|
hashes: string[],
|
|
token: string
|
|
): Promise<Map<string, string>> {
|
|
const out = new Map<string, string>();
|
|
const unique = uniq(hashes).slice(0, 40);
|
|
if (!unique.length) return out;
|
|
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
|
try {
|
|
const raw = await graphGet<{
|
|
data?: { hash?: string; url?: string; permalink_url?: string }[];
|
|
}>(
|
|
`${act}/adimages`,
|
|
{
|
|
hashes: JSON.stringify(unique),
|
|
fields: 'hash,url,permalink_url',
|
|
},
|
|
token
|
|
);
|
|
for (const row of raw.data || []) {
|
|
const h = str(row.hash);
|
|
const u = str(row.url) || str(row.permalink_url);
|
|
if (h && u) out.set(h, u);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Aggregate ad_daily rows by ad_id for a platform. */
|
|
export function aggregateDailyByAd(
|
|
rows: {
|
|
platform?: string;
|
|
ad_id?: string | null;
|
|
ad_name?: string | null;
|
|
campaign_name?: string | null;
|
|
spend?: number;
|
|
impressions?: number;
|
|
clicks?: number;
|
|
platform_leads?: number;
|
|
conversions?: number;
|
|
date?: string;
|
|
}[],
|
|
platform: 'meta' | 'google',
|
|
since: string,
|
|
until: string
|
|
): DailyAgg[] {
|
|
const map = new Map<string, DailyAgg>();
|
|
for (const r of rows) {
|
|
if ((r.platform || '').toLowerCase() !== platform) continue;
|
|
const d = (r.date || '').slice(0, 10);
|
|
if (d < since || d > until) continue;
|
|
const adId = str(r.ad_id);
|
|
if (!adId) continue;
|
|
const cur = map.get(adId) || {
|
|
adId,
|
|
adName: str(r.ad_name) || adId,
|
|
campaignName: str(r.campaign_name),
|
|
spend: 0,
|
|
impressions: 0,
|
|
clicks: 0,
|
|
conversions: 0,
|
|
};
|
|
cur.spend += n(r.spend);
|
|
cur.impressions += n(r.impressions);
|
|
cur.clicks += n(r.clicks);
|
|
cur.conversions += n(r.platform_leads ?? r.conversions);
|
|
if (str(r.ad_name)) cur.adName = str(r.ad_name);
|
|
if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name);
|
|
map.set(adId, cur);
|
|
}
|
|
return [...map.values()]
|
|
.filter(
|
|
(a) =>
|
|
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
|
|
)
|
|
.sort((a, b) => b.spend - a.spend);
|
|
}
|
|
|
|
async function fetchMetaCreativeBundle(
|
|
adId: string,
|
|
token: string
|
|
): Promise<{
|
|
adId: string;
|
|
adName: string;
|
|
campaignName: string;
|
|
adsetName: string;
|
|
status: string;
|
|
creative: Record<string, unknown>;
|
|
} | null> {
|
|
try {
|
|
const raw = await graphGet<{
|
|
id?: string;
|
|
name?: string;
|
|
effective_status?: string;
|
|
campaign?: { name?: string };
|
|
adset?: { name?: string };
|
|
creative?: Record<string, unknown>;
|
|
}>(
|
|
adId,
|
|
{
|
|
fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`,
|
|
thumbnail_width: '1080',
|
|
thumbnail_height: '1920',
|
|
},
|
|
token
|
|
);
|
|
|
|
let creative = (raw.creative || {}) as Record<string, unknown>;
|
|
const cid = str(creative.id);
|
|
if (cid) {
|
|
try {
|
|
const detail = await graphGet<Record<string, unknown>>(
|
|
cid,
|
|
{
|
|
fields: CREATIVE_FIELDS,
|
|
thumbnail_width: '1080',
|
|
thumbnail_height: '1920',
|
|
},
|
|
token
|
|
);
|
|
creative = { ...creative, ...detail };
|
|
} catch {
|
|
/* keep */
|
|
}
|
|
}
|
|
|
|
return {
|
|
adId: str(raw.id) || adId,
|
|
adName: str(raw.name),
|
|
campaignName: str(raw.campaign?.name),
|
|
adsetName: str(raw.adset?.name),
|
|
status: str(raw.effective_status),
|
|
creative,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
type MetaAdInsight = {
|
|
adId: string;
|
|
adName: string;
|
|
campaignId: string;
|
|
campaignName: string;
|
|
spend: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
conversions: number;
|
|
};
|
|
|
|
/**
|
|
* ad_daily is campaign-level for Meta (ad_id === campaign_id).
|
|
* Pull real ad-level insights for the range so creatives resolve correctly.
|
|
* Splits long ranges into ~14d windows (Meta often fails on large ad-level pulls).
|
|
*/
|
|
async function fetchMetaAdLevelInsights(
|
|
accountId: string,
|
|
token: string,
|
|
since: string,
|
|
until: string,
|
|
limit: number
|
|
): Promise<MetaAdInsight[]> {
|
|
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
|
const windows: { since: string; until: string }[] = [];
|
|
{
|
|
const start = new Date(since + 'T00:00:00Z');
|
|
const end = new Date(until + 'T00:00:00Z');
|
|
let cur = new Date(start);
|
|
while (cur <= end) {
|
|
const wEnd = new Date(cur);
|
|
wEnd.setUTCDate(wEnd.getUTCDate() + 13);
|
|
if (wEnd > end) wEnd.setTime(end.getTime());
|
|
windows.push({
|
|
since: cur.toISOString().slice(0, 10),
|
|
until: wEnd.toISOString().slice(0, 10),
|
|
});
|
|
cur = new Date(wEnd);
|
|
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
}
|
|
}
|
|
|
|
const byAd = new Map<string, MetaAdInsight>();
|
|
|
|
for (const w of windows) {
|
|
let after: string | undefined;
|
|
for (let page = 0; page < 8; page++) {
|
|
const params: Record<string, string> = {
|
|
level: 'ad',
|
|
time_increment: 'all_days',
|
|
time_range: JSON.stringify({ since: w.since, until: w.until }),
|
|
fields:
|
|
'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions',
|
|
limit: '100',
|
|
};
|
|
if (after) params.after = after;
|
|
const raw = await graphGet<{
|
|
data?: {
|
|
ad_id?: string;
|
|
ad_name?: string;
|
|
campaign_id?: string;
|
|
campaign_name?: string;
|
|
impressions?: string;
|
|
clicks?: string;
|
|
spend?: string;
|
|
actions?: { action_type?: string; value?: string }[];
|
|
}[];
|
|
paging?: { cursors?: { after?: string }; next?: string };
|
|
}>(`${act}/insights`, params, token);
|
|
|
|
for (const row of raw.data || []) {
|
|
const adId = str(row.ad_id);
|
|
if (!adId) continue;
|
|
const conversions = leadFromMetaActions(row.actions);
|
|
const cur = byAd.get(adId) || {
|
|
adId,
|
|
adName: str(row.ad_name),
|
|
campaignId: str(row.campaign_id),
|
|
campaignName: str(row.campaign_name),
|
|
spend: 0,
|
|
impressions: 0,
|
|
clicks: 0,
|
|
conversions: 0,
|
|
};
|
|
cur.adName = str(row.ad_name) || cur.adName;
|
|
cur.campaignName = str(row.campaign_name) || cur.campaignName;
|
|
cur.spend += Math.round(n(row.spend) * 100) / 100;
|
|
cur.impressions += n(row.impressions);
|
|
cur.clicks += n(row.clicks);
|
|
cur.conversions += conversions;
|
|
byAd.set(adId, cur);
|
|
}
|
|
after = raw.paging?.cursors?.after;
|
|
if (!raw.paging?.next || !after) break;
|
|
}
|
|
}
|
|
|
|
return [...byAd.values()]
|
|
.filter(
|
|
(a) =>
|
|
a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0
|
|
)
|
|
.sort((a, b) => b.spend - a.spend)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
function cardFromBundle(
|
|
agg: {
|
|
adId: string;
|
|
adName: string;
|
|
campaignName: string;
|
|
spend: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
conversions: number;
|
|
},
|
|
b: Awaited<ReturnType<typeof fetchMetaCreativeBundle>>,
|
|
pack: Extracted,
|
|
hashMap: Map<string, string>
|
|
): AdCreativeCard {
|
|
const assets = [...pack.assets];
|
|
for (const h of pack.hashes) {
|
|
const u = hashMap.get(h);
|
|
if (u && !assets.some((a) => a.url === u)) {
|
|
assets.push({ kind: 'image', url: u, label: 'hash' });
|
|
}
|
|
}
|
|
const creative = b?.creative || {};
|
|
const fallback =
|
|
str(creative.image_url) || str(creative.thumbnail_url) || '';
|
|
const mediaType = detectMediaType(creative);
|
|
const picked = pickMetaPreviewAsset(assets, mediaType, fallback);
|
|
const headlines = pack.headlines;
|
|
const bodies = pack.bodies;
|
|
const descriptions = pack.descriptions;
|
|
return {
|
|
platform: 'meta',
|
|
adId: b?.adId || agg.adId,
|
|
adName: b?.adName || agg.adName,
|
|
campaignName: b?.campaignName || agg.campaignName,
|
|
adsetName: b?.adsetName,
|
|
spend: Math.round(agg.spend * 100) / 100,
|
|
impressions: agg.impressions,
|
|
clicks: agg.clicks,
|
|
conversions: agg.conversions,
|
|
cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null,
|
|
mediaType,
|
|
thumbnailUrl: picked.url,
|
|
imageUrl: picked.url,
|
|
headline: headlines.join('\n'),
|
|
primaryText: bodies.join('\n') || descriptions.join('\n'),
|
|
headlines,
|
|
bodies,
|
|
descriptions,
|
|
assets,
|
|
linkUrl: pack.linkUrl || undefined,
|
|
cta: pack.cta || undefined,
|
|
status: b?.status,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build Meta creative cards: live ad-level insights + creative assets.
|
|
* Never fails the UI: on any Graph error → campaign ads → warehouse cards.
|
|
* Soft-fail warnings are omitted when we still return ads.
|
|
*/
|
|
export async function fetchMetaCreativeCards(opts: {
|
|
tenantId: string;
|
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
|
|
since: string;
|
|
until: string;
|
|
limit?: number;
|
|
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
|
const warehouse = () =>
|
|
metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16);
|
|
|
|
try {
|
|
return await fetchMetaCreativeCardsInner(opts, warehouse);
|
|
} catch {
|
|
const ads = warehouse();
|
|
return ads.length
|
|
? { ads }
|
|
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
|
|
}
|
|
}
|
|
|
|
async function fetchMetaCreativeCardsInner(
|
|
opts: {
|
|
tenantId: string;
|
|
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
|
|
since: string;
|
|
until: string;
|
|
limit?: number;
|
|
},
|
|
warehouse: () => AdCreativeCard[]
|
|
): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
|
|
const token = await resolveMetaAccessToken();
|
|
if (!token) {
|
|
const ads = warehouse();
|
|
return ads.length
|
|
? { ads }
|
|
: { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
|
|
}
|
|
|
|
const account = await resolveMetaAccountId(opts.tenantId);
|
|
if (!account) {
|
|
const ads = warehouse();
|
|
return ads.length
|
|
? { ads }
|
|
: { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
|
|
}
|
|
|
|
const limit = opts.limit ?? 16;
|
|
let targets: {
|
|
adId: string;
|
|
adName: string;
|
|
campaignName: string;
|
|
spend: number;
|
|
impressions: number;
|
|
clicks: number;
|
|
conversions: number;
|
|
}[] = [];
|
|
|
|
try {
|
|
targets = await fetchMetaAdLevelInsights(
|
|
account,
|
|
token,
|
|
opts.since,
|
|
opts.until,
|
|
limit
|
|
);
|
|
} catch {
|
|
/* fall through — never surface ad-level Graph errors */
|
|
}
|
|
|
|
// Fallback: expand campaigns from ad_daily → ads under each campaign
|
|
if (!targets.length) {
|
|
const campaigns = aggregateDailyByAd(
|
|
opts.dailyRows,
|
|
'meta',
|
|
opts.since,
|
|
opts.until
|
|
).slice(0, 8);
|
|
for (const camp of campaigns) {
|
|
try {
|
|
const raw = await graphGet<{
|
|
data?: { id?: string; name?: string; effective_status?: string }[];
|
|
}>(
|
|
`${camp.adId}/ads`,
|
|
{
|
|
fields: 'id,name,effective_status',
|
|
limit: '6',
|
|
filtering: JSON.stringify([
|
|
{
|
|
field: 'effective_status',
|
|
operator: 'IN',
|
|
value: ['ACTIVE', 'PAUSED', 'CAMPAIGN_PAUSED', 'ADSET_PAUSED'],
|
|
},
|
|
]),
|
|
},
|
|
token
|
|
);
|
|
const ads = raw.data || [];
|
|
const share = ads.length || 1;
|
|
for (const ad of ads) {
|
|
const id = str(ad.id);
|
|
if (!id) continue;
|
|
targets.push({
|
|
adId: id,
|
|
adName:
|
|
str(ad.name) ||
|
|
cleanMetaAdName(camp.adName) ||
|
|
camp.campaignName,
|
|
campaignName: camp.campaignName,
|
|
spend: camp.spend / share,
|
|
impressions: Math.round(camp.impressions / share),
|
|
clicks: Math.round(camp.clicks / share),
|
|
conversions: camp.conversions / share,
|
|
});
|
|
}
|
|
} catch {
|
|
/* skip campaign */
|
|
}
|
|
if (targets.length >= limit) break;
|
|
}
|
|
targets = targets.slice(0, limit);
|
|
}
|
|
|
|
// Last resort: warehouse campaign cards (always succeeds if there is spend)
|
|
if (!targets.length) {
|
|
const ads = warehouse();
|
|
return ads.length
|
|
? { ads }
|
|
: { ads: [], warning: 'Sin actividad Meta en este periodo' };
|
|
}
|
|
|
|
const bundles: (Awaited<ReturnType<typeof fetchMetaCreativeBundle>>)[] = [];
|
|
for (let i = 0; i < targets.length; i += 6) {
|
|
const chunk = targets.slice(i, i + 6);
|
|
const part = await Promise.all(
|
|
chunk.map(async (a) => {
|
|
try {
|
|
return await fetchMetaCreativeBundle(a.adId, token);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
bundles.push(...part);
|
|
}
|
|
|
|
const allHashes: string[] = [];
|
|
const packs = bundles.map((b) => {
|
|
const pack = extractCreativePack(b?.creative || {});
|
|
allHashes.push(...pack.hashes);
|
|
return pack;
|
|
});
|
|
let hashMap = new Map<string, string>();
|
|
try {
|
|
hashMap = await resolveImageHashes(account, allHashes, token);
|
|
} catch {
|
|
/* previews may still work from creative URLs */
|
|
}
|
|
|
|
const out: AdCreativeCard[] = targets.map((agg, idx) =>
|
|
cardFromBundle(agg, bundles[idx], packs[idx], hashMap)
|
|
);
|
|
|
|
if (!out.length) {
|
|
return { ads: warehouse() };
|
|
}
|
|
|
|
return { ads: out };
|
|
}
|
|
|
|
/**
|
|
* Account-level Meta clicks for the period:
|
|
* - clicks = todos (Ads Manager)
|
|
* - linkClicks = clics en el enlace
|
|
*/
|
|
export async function fetchMetaAccountClickSplit(opts: {
|
|
tenantId: string;
|
|
since: string;
|
|
until: string;
|
|
}): Promise<{ clicks: number; linkClicks: number; spend: number } | null> {
|
|
const token = await resolveMetaAccessToken();
|
|
if (!token) return null;
|
|
const account = await resolveMetaAccountId(opts.tenantId);
|
|
if (!account) return null;
|
|
const act = account.startsWith('act_') ? account : `act_${account}`;
|
|
const timeRange = JSON.stringify({ since: opts.since, until: opts.until });
|
|
try {
|
|
const raw = await graphGet<{
|
|
data?: {
|
|
spend?: string;
|
|
clicks?: string;
|
|
actions?: { action_type?: string; value?: string }[];
|
|
}[];
|
|
}>(
|
|
`${act}/insights`,
|
|
{
|
|
fields: 'spend,clicks,actions',
|
|
level: 'account',
|
|
time_increment: 'all_days',
|
|
time_range: timeRange,
|
|
limit: '1',
|
|
},
|
|
token
|
|
);
|
|
const row = raw.data?.[0];
|
|
if (!row) return null;
|
|
const clicks = n(row.clicks);
|
|
return {
|
|
clicks,
|
|
linkClicks: linkClicksFromMetaActions(row.actions, clicks),
|
|
spend: Math.round(n(row.spend) * 100) / 100,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|