195 lines
6.2 KiB
TypeScript
195 lines
6.2 KiB
TypeScript
// Ad Tracking Engine — Impression/Click recording, deduplication, rollups
|
|
|
|
import type { AdTrackingEvent, PlacementDailyStats } from '@/lib/types/ads';
|
|
import type { SurfaceKey } from '@/lib/types/ad-control';
|
|
|
|
const TRACKING_KEY = 'ad_tracking_events';
|
|
const DAILY_STATS_KEY = 'ad_daily_stats';
|
|
const DEDUPE_WINDOW_MS = 30_000; // 30 seconds
|
|
|
|
// --- Persistence ---
|
|
|
|
function getTrackingStore(): AdTrackingEvent[] {
|
|
if (typeof window === 'undefined') return [];
|
|
try { return JSON.parse(localStorage.getItem(TRACKING_KEY) || '[]'); }
|
|
catch { return []; }
|
|
}
|
|
|
|
function saveTrackingStore(events: AdTrackingEvent[]) {
|
|
if (typeof window === 'undefined') return;
|
|
// Cap at 5000 to avoid localStorage limits
|
|
localStorage.setItem(TRACKING_KEY, JSON.stringify(events.slice(-5000)));
|
|
}
|
|
|
|
function getDailyStatsStore(): PlacementDailyStats[] {
|
|
if (typeof window === 'undefined') return [];
|
|
try { return JSON.parse(localStorage.getItem(DAILY_STATS_KEY) || '[]'); }
|
|
catch { return []; }
|
|
}
|
|
|
|
function saveDailyStatsStore(stats: PlacementDailyStats[]) {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(DAILY_STATS_KEY, JSON.stringify(stats));
|
|
}
|
|
|
|
// --- Deduplication ---
|
|
|
|
function isDuplicate(events: AdTrackingEvent[], newEvent: Omit<AdTrackingEvent, 'id'>): boolean {
|
|
const cutoff = new Date(new Date(newEvent.timestamp).getTime() - DEDUPE_WINDOW_MS).toISOString();
|
|
return events.some(e =>
|
|
e.type === newEvent.type &&
|
|
e.placementId === newEvent.placementId &&
|
|
e.anonId === newEvent.anonId &&
|
|
e.timestamp > cutoff
|
|
);
|
|
}
|
|
|
|
// --- Record Events ---
|
|
|
|
export function recordImpression(data: {
|
|
placementId: string;
|
|
campaignId: string;
|
|
surfaceKey: SurfaceKey;
|
|
eventId: string;
|
|
userId?: string | null;
|
|
anonId: string;
|
|
sessionId: string;
|
|
device?: string;
|
|
cityId?: string;
|
|
}): { success: boolean; deduplicated?: boolean } {
|
|
const store = getTrackingStore();
|
|
const now = new Date().toISOString();
|
|
|
|
const event: Omit<AdTrackingEvent, 'id'> = {
|
|
type: 'IMPRESSION',
|
|
placementId: data.placementId,
|
|
campaignId: data.campaignId,
|
|
surfaceKey: data.surfaceKey,
|
|
eventId: data.eventId,
|
|
userId: data.userId || null,
|
|
anonId: data.anonId,
|
|
sessionId: data.sessionId,
|
|
timestamp: now,
|
|
device: data.device || 'unknown',
|
|
cityId: data.cityId || 'unknown',
|
|
};
|
|
|
|
if (isDuplicate(store, event)) {
|
|
return { success: true, deduplicated: true };
|
|
}
|
|
|
|
const fullEvent: AdTrackingEvent = {
|
|
...event,
|
|
id: `te-imp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
};
|
|
|
|
store.push(fullEvent);
|
|
saveTrackingStore(store);
|
|
return { success: true };
|
|
}
|
|
|
|
export function recordClick(data: {
|
|
placementId: string;
|
|
campaignId: string;
|
|
surfaceKey: SurfaceKey;
|
|
eventId: string;
|
|
userId?: string | null;
|
|
anonId: string;
|
|
sessionId: string;
|
|
device?: string;
|
|
cityId?: string;
|
|
}): { success: boolean } {
|
|
const store = getTrackingStore();
|
|
const now = new Date().toISOString();
|
|
|
|
const fullEvent: AdTrackingEvent = {
|
|
id: `te-clk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
type: 'CLICK',
|
|
placementId: data.placementId,
|
|
campaignId: data.campaignId,
|
|
surfaceKey: data.surfaceKey,
|
|
eventId: data.eventId,
|
|
userId: data.userId || null,
|
|
anonId: data.anonId,
|
|
sessionId: data.sessionId,
|
|
timestamp: now,
|
|
device: data.device || 'unknown',
|
|
cityId: data.cityId || 'unknown',
|
|
};
|
|
|
|
store.push(fullEvent);
|
|
saveTrackingStore(store);
|
|
return { success: true };
|
|
}
|
|
|
|
// --- Queries ---
|
|
|
|
export function getTrackingEvents(campaignId?: string, type?: 'IMPRESSION' | 'CLICK'): AdTrackingEvent[] {
|
|
let events = getTrackingStore();
|
|
if (campaignId) events = events.filter(e => e.campaignId === campaignId);
|
|
if (type) events = events.filter(e => e.type === type);
|
|
return events;
|
|
}
|
|
|
|
export function getUserImpressionCount(anonId: string, campaignId: string, date: string): number {
|
|
const events = getTrackingStore();
|
|
return events.filter(e =>
|
|
e.type === 'IMPRESSION' &&
|
|
e.anonId === anonId &&
|
|
e.campaignId === campaignId &&
|
|
e.timestamp.startsWith(date)
|
|
).length;
|
|
}
|
|
|
|
export function getTodaySpend(campaignId: string, cpmRate: number): number {
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const impressions = getTrackingStore().filter(e =>
|
|
e.type === 'IMPRESSION' &&
|
|
e.campaignId === campaignId &&
|
|
e.timestamp.startsWith(today)
|
|
).length;
|
|
return Number(((impressions / 1000) * cpmRate).toFixed(2));
|
|
}
|
|
|
|
// --- Daily Stats Rollup ---
|
|
|
|
export function computeDailyStats(campaignId: string, billingModel: string, bid: number): PlacementDailyStats[] {
|
|
const events = getTrackingStore().filter(e => e.campaignId === campaignId);
|
|
|
|
// Group by date + surfaceKey
|
|
const buckets: Record<string, { impressions: number; clicks: number; surfaceKey: SurfaceKey; placementId: string }> = {};
|
|
|
|
for (const e of events) {
|
|
const date = e.timestamp.slice(0, 10);
|
|
const key = `${date}|${e.surfaceKey}`;
|
|
if (!buckets[key]) {
|
|
buckets[key] = { impressions: 0, clicks: 0, surfaceKey: e.surfaceKey, placementId: e.placementId };
|
|
}
|
|
if (e.type === 'IMPRESSION') buckets[key].impressions++;
|
|
else buckets[key].clicks++;
|
|
}
|
|
|
|
return Object.entries(buckets).map(([key, data]) => {
|
|
const [date] = key.split('|');
|
|
let spend = 0;
|
|
if (billingModel === 'CPM') spend = (data.impressions / 1000) * bid;
|
|
else if (billingModel === 'CPC') spend = data.clicks * bid;
|
|
// FIXED: spread evenly (simplified)
|
|
else spend = bid / 30; // assume 30-day campaign
|
|
|
|
const ctr = data.impressions > 0 ? data.clicks / data.impressions : 0;
|
|
|
|
return {
|
|
id: `ds-${key}`,
|
|
campaignId,
|
|
placementId: data.placementId,
|
|
surfaceKey: data.surfaceKey,
|
|
date,
|
|
impressions: data.impressions,
|
|
clicks: data.clicks,
|
|
ctr: Number(ctr.toFixed(4)),
|
|
spend: Number(spend.toFixed(2)),
|
|
};
|
|
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|