import fetch from 'node-fetch'; import { TimeManager } from '../utils/time.utils.js'; import { RedisService } from './redis.service.js'; const METRIC_IDS = { PLACED_ORDER: 'Y8cqcF' }; export class ReportingService { constructor(apiKey, apiRevision) { this.apiKey = apiKey; this.apiRevision = apiRevision; this.baseUrl = 'https://a.klaviyo.com/api'; this.timeManager = new TimeManager(); this.redisService = new RedisService(); this._pendingReportRequest = null; } async getCampaignReports(params = {}) { try { // Check if there's a pending request if (this._pendingReportRequest) { console.log('[ReportingService] Using pending campaign report request'); return this._pendingReportRequest; } // Try to get from cache first const cacheKey = this.redisService._getCacheKey('campaign_reports', params); let cachedData = null; try { cachedData = await this.redisService.get(`${cacheKey}:raw`); if (cachedData) { console.log('[ReportingService] Using cached campaign report data'); return cachedData; } } catch (cacheError) { console.warn('[ReportingService] Cache error:', cacheError); } // Create new request promise this._pendingReportRequest = (async () => { console.log('[ReportingService] Fetching fresh campaign report data'); const range = this.timeManager.getDateRange(params.timeRange || 'last30days'); const payload = { data: { type: "campaign-values-report", attributes: { timeframe: { start: range.start.toISO(), end: range.end.toISO() }, statistics: [ "delivery_rate", "delivered", "recipients", "open_rate", "opens_unique", "opens", "click_rate", "clicks_unique", "click_to_open_rate", "conversion_value", "conversion_uniques" ], conversion_metric_id: METRIC_IDS.PLACED_ORDER, filter: 'equals(send_channel,"email")' } } }; const response = await fetch(`${this.baseUrl}/campaign-values-reports`, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, 'revision': this.apiRevision }, body: JSON.stringify(payload) }); if (!response.ok) { const errorData = await response.json(); console.error('[ReportingService] API Error:', errorData); throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); } const reportData = await response.json(); console.log('[ReportingService] Raw report data:', JSON.stringify(reportData, null, 2)); // Get campaign IDs from the report const campaignIds = reportData.data?.attributes?.results?.map(result => result.groupings?.campaign_id ).filter(Boolean) || []; console.log('[ReportingService] Extracted campaign IDs:', campaignIds); // Get campaign details including send time and subject lines const campaignDetails = await this.getCampaignDetails(campaignIds); console.log('[ReportingService] Campaign details:', JSON.stringify(campaignDetails, null, 2)); // Merge campaign details with report data const enrichedData = { data: reportData.data.attributes.results.map(result => { const campaignId = result.groupings.campaign_id; const details = campaignDetails.find(detail => detail.id === campaignId); const message = details?.included?.find(item => item.type === 'campaign-message'); return { id: campaignId, name: details.attributes.name, subject: details.attributes.subject, send_time: details.attributes.send_time, attributes: { statistics: { delivery_rate: result.statistics.delivery_rate, delivered: result.statistics.delivered, recipients: result.statistics.recipients, open_rate: result.statistics.open_rate, opens_unique: result.statistics.opens_unique, opens: result.statistics.opens, click_rate: result.statistics.click_rate, clicks_unique: result.statistics.clicks_unique, click_to_open_rate: result.statistics.click_to_open_rate, conversion_value: result.statistics.conversion_value, conversion_uniques: result.statistics.conversion_uniques } } }; }) .sort((a, b) => { const dateA = new Date(a.send_time); const dateB = new Date(b.send_time); return dateB - dateA; // Sort by date descending }) }; console.log('[ReportingService] Enriched data:', JSON.stringify(enrichedData, null, 2)); // Cache the enriched response for 10 minutes try { await this.redisService.set(`${cacheKey}:raw`, enrichedData, 600); } catch (cacheError) { console.warn('[ReportingService] Cache set error:', cacheError); } return enrichedData; })(); const result = await this._pendingReportRequest; this._pendingReportRequest = null; return result; } catch (error) { console.error('[ReportingService] Error fetching campaign reports:', error); this._pendingReportRequest = null; throw error; } } async getCampaignDetails(campaignIds = []) { if (!Array.isArray(campaignIds) || campaignIds.length === 0) { return []; } const fetchWithTimeout = async (campaignId, retries = 3) => { for (let i = 0; i < retries; i++) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout const response = await fetch( `${this.baseUrl}/campaigns/${campaignId}?include=campaign-messages`, { headers: { 'Accept': 'application/json', 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, 'revision': this.apiRevision }, signal: controller.signal } ); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`Failed to fetch campaign ${campaignId}: ${response.status}`); } const data = await response.json(); if (!data.data) { throw new Error(`Invalid response for campaign ${campaignId}`); } const message = data.included?.find(item => item.type === 'campaign-message'); return { id: data.data.id, type: data.data.type, attributes: { ...data.data.attributes, name: data.data.attributes.name, send_time: data.data.attributes.send_time, subject: message?.attributes?.content?.subject } }; } catch (error) { if (i === retries - 1) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // Exponential backoff } } }; // Process in smaller chunks to avoid overwhelming the API const chunkSize = 10; const campaignDetails = []; for (let i = 0; i < campaignIds.length; i += chunkSize) { const chunk = campaignIds.slice(i, i + chunkSize); const results = await Promise.all( chunk.map(id => fetchWithTimeout(id).catch(error => { console.error(`Failed to fetch campaign ${id}:`, error); return null; })) ); campaignDetails.push(...results.filter(Boolean)); if (i + chunkSize < campaignIds.length) { await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay between chunks } } return campaignDetails; } }