feat: previews de creatividades en Ad Tracker (estilo Brava)

Meta Graph en vivo + ficha Google; galería por anuncio en resumen/Meta/Google.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:16:14 +02:00
parent be45df300b
commit 86225a701e
3 changed files with 663 additions and 3 deletions

View File

@@ -0,0 +1,324 @@
/** Meta ad creatives for Ad Tracker (Brava-style previews). */
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
const META_GRAPH = 'https://graph.facebook.com/v21.0';
const CREATIVE_FIELDS = [
'id',
'name',
'title',
'body',
'thumbnail_url',
'image_url',
'image_hash',
'video_id',
'object_story_spec',
'asset_feed_spec',
'effective_object_story_id',
].join(',');
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;
thumbnailUrl: string;
imageUrl: string;
headline: string;
primaryText: string;
status?: 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 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';
}
function extractTexts(creative: Record<string, unknown>): {
headline: string;
primaryText: string;
} {
const titles: string[] = [];
const bodies: string[] = [];
if (creative.title) titles.push(str(creative.title));
if (creative.body) bodies.push(str(creative.body));
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;
if (link?.name) titles.push(str(link.name));
if (link?.message) bodies.push(str(link.message));
if (video?.title) titles.push(str(video.title));
if (video?.message) bodies.push(str(video.message));
const feed = creative.asset_feed_spec as Record<string, unknown> | undefined;
const feedTitles = feed?.titles as { text?: string }[] | undefined;
const feedBodies = feed?.bodies as { text?: string }[] | undefined;
for (const t of feedTitles || []) if (t?.text) titles.push(str(t.text));
for (const b of feedBodies || []) if (b?.text) bodies.push(str(b.text));
return {
headline: [...new Set(titles.filter(Boolean))].slice(0, 5).join('\n'),
primaryText: [...new Set(bodies.filter(Boolean))].slice(0, 3).join('\n'),
};
}
async function graphGet<T>(
path: string,
params: Record<string, string>,
token: string
): Promise<T> {
const q = new URLSearchParams({ ...params, access_token: token });
const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' });
if (!r.ok) {
throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`);
}
return (await r.json()) as T;
}
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;
}
/** 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()].sort((a, b) => b.spend - a.spend);
}
async function fetchMetaCreativeBundle(
adId: string,
token: string
): Promise<{
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: '1080',
}, 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: '1080',
},
token
);
creative = { ...creative, ...detail };
} catch {
/* keep lightweight */
}
}
return {
adName: str(raw.name),
campaignName: str(raw.campaign?.name),
adsetName: str(raw.adset?.name),
status: str(raw.effective_status),
creative,
};
} catch {
return null;
}
}
/**
* Build Meta creative cards for a tenant: metrics from ad_daily + live Meta creative assets.
*/
export async function fetchMetaCreativeCards(opts: {
tenantId: string;
dailyRows: Parameters<typeof aggregateDailyByAd>[0];
since: string;
until: string;
limit?: number;
}): Promise<{ ads: AdCreativeCard[]; warning?: string }> {
const token = await resolveMetaAccessToken();
if (!token) {
return { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' };
}
const account = await resolveMetaAccountId(opts.tenantId);
if (!account) {
return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' };
}
const aggs = aggregateDailyByAd(
opts.dailyRows,
'meta',
opts.since,
opts.until
).slice(0, opts.limit ?? 40);
if (!aggs.length) {
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
}
const out: AdCreativeCard[] = [];
// Sequential batches of 5 to respect rate limits
for (let i = 0; i < aggs.length; i += 5) {
const chunk = aggs.slice(i, i + 5);
const bundles = await Promise.all(
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
);
chunk.forEach((agg, idx) => {
const b = bundles[idx];
const creative = b?.creative || {};
const texts = extractTexts(creative);
const imageUrl =
str(creative.image_url) ||
str(creative.thumbnail_url) ||
'';
const thumbnailUrl =
str(creative.thumbnail_url) || imageUrl;
out.push({
platform: 'meta',
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: detectMediaType(creative),
thumbnailUrl,
imageUrl: imageUrl || thumbnailUrl,
headline: texts.headline,
primaryText: texts.primaryText,
status: b?.status,
});
});
}
return { ads: out };
}
/** Google cards from ad_daily only (no asset pull yet — text fallback). */
export function buildGoogleCardsFromDaily(
dailyRows: Parameters<typeof aggregateDailyByAd>[0],
since: string,
until: string,
limit = 40
): AdCreativeCard[] {
return aggregateDailyByAd(dailyRows, 'google', since, until)
.slice(0, limit)
.map((agg) => ({
platform: 'google' as const,
adId: agg.adId,
adName: agg.adName,
campaignName: agg.campaignName,
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: 'rsa',
thumbnailUrl: '',
imageUrl: '',
headline: agg.adName,
primaryText: agg.campaignName,
status: undefined,
}));
}