Phase 4 + 6
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
import { RedisService } from './redis.service.js';
|
||||
|
||||
export class CampaignsService {
|
||||
constructor(apiKey, apiRevision, redis) {
|
||||
this.apiKey = apiKey;
|
||||
this.apiRevision = apiRevision;
|
||||
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||
this.timeManager = new TimeManager();
|
||||
this.redisService = new RedisService(redis);
|
||||
}
|
||||
|
||||
async getCampaigns(params = {}) {
|
||||
try {
|
||||
// Add request debouncing
|
||||
const requestKey = JSON.stringify(params);
|
||||
if (this._pendingRequests && this._pendingRequests[requestKey]) {
|
||||
return this._pendingRequests[requestKey];
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||
let cachedData = null;
|
||||
try {
|
||||
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||
}
|
||||
|
||||
this._pendingRequests = this._pendingRequests || {};
|
||||
this._pendingRequests[requestKey] = (async () => {
|
||||
let allCampaigns = [];
|
||||
let nextCursor = params.pageCursor;
|
||||
let pageCount = 0;
|
||||
|
||||
const filter = params.filter || this._buildFilter(params);
|
||||
|
||||
do {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filter) {
|
||||
queryParams.append('filter', filter);
|
||||
}
|
||||
queryParams.append('sort', params.sort || '-send_time');
|
||||
|
||||
if (nextCursor) {
|
||||
queryParams.append('page[cursor]', nextCursor);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/campaigns?${queryParams.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||
'revision': this.apiRevision
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('[CampaignsService] API Error:', errorData);
|
||||
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
allCampaigns = allCampaigns.concat(responseData.data || []);
|
||||
pageCount++;
|
||||
|
||||
nextCursor = responseData.links?.next ?
|
||||
new URL(responseData.links.next).searchParams.get('page[cursor]') : null;
|
||||
|
||||
if (nextCursor) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('[CampaignsService] Fetch error:', fetchError);
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
} while (nextCursor);
|
||||
|
||||
const transformedCampaigns = this._transformCampaigns(allCampaigns);
|
||||
|
||||
const result = {
|
||||
data: transformedCampaigns,
|
||||
meta: {
|
||||
total_count: transformedCampaigns.length,
|
||||
page_count: pageCount
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const ttl = this.redisService._getTTL(params.timeRange);
|
||||
await this.redisService.set(`${cacheKey}:raw`, result, ttl);
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache set error:', cacheError);
|
||||
}
|
||||
|
||||
delete this._pendingRequests[requestKey];
|
||||
return result;
|
||||
})();
|
||||
|
||||
return await this._pendingRequests[requestKey];
|
||||
} catch (error) {
|
||||
console.error('[CampaignsService] Error fetching campaigns:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_buildFilter(params) {
|
||||
const filters = [];
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
const startUtc = this.timeManager.formatForAPI(params.startDate);
|
||||
const endUtc = this.timeManager.formatForAPI(params.endDate);
|
||||
|
||||
filters.push(`greater-or-equal(send_time,${startUtc})`);
|
||||
filters.push(`less-than(send_time,${endUtc})`);
|
||||
}
|
||||
|
||||
if (params.status) {
|
||||
filters.push(`equals(status,"${params.status}")`);
|
||||
}
|
||||
|
||||
if (params.customFilters) {
|
||||
filters.push(...params.customFilters);
|
||||
}
|
||||
|
||||
return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null;
|
||||
}
|
||||
|
||||
async getCampaignsByTimeRange(timeRange, options = {}) {
|
||||
const range = this.timeManager.getDateRange(timeRange);
|
||||
if (!range) {
|
||||
throw new Error('Invalid time range specified');
|
||||
}
|
||||
|
||||
const params = {
|
||||
timeRange,
|
||||
startDate: range.start.toISO(),
|
||||
endDate: range.end.toISO(),
|
||||
...options
|
||||
};
|
||||
|
||||
// Try to get from cache first
|
||||
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||
let cachedData = null;
|
||||
try {
|
||||
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||
}
|
||||
|
||||
return this.getCampaigns(params);
|
||||
}
|
||||
|
||||
_transformCampaigns(campaigns) {
|
||||
if (!Array.isArray(campaigns)) {
|
||||
console.warn('[CampaignsService] Campaigns is not an array:', campaigns);
|
||||
return [];
|
||||
}
|
||||
|
||||
return campaigns.map(campaign => {
|
||||
try {
|
||||
const stats = campaign.attributes?.campaign_message?.stats || {};
|
||||
|
||||
return {
|
||||
id: campaign.id,
|
||||
name: campaign.attributes?.name || "Unnamed Campaign",
|
||||
subject: campaign.attributes?.campaign_message?.subject || "",
|
||||
send_time: campaign.attributes?.send_time,
|
||||
stats: {
|
||||
delivery_rate: stats.delivery_rate || 0,
|
||||
delivered: stats.delivered || 0,
|
||||
recipients: stats.recipients || 0,
|
||||
open_rate: stats.open_rate || 0,
|
||||
opens_unique: stats.opens_unique || 0,
|
||||
opens: stats.opens || 0,
|
||||
clicks_unique: stats.clicks_unique || 0,
|
||||
click_rate: stats.click_rate || 0,
|
||||
click_to_open_rate: stats.click_to_open_rate || 0,
|
||||
conversion_value: stats.conversion_value || 0,
|
||||
conversion_uniques: stats.conversion_uniques || 0
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[CampaignsService] Error transforming campaign:', error, campaign);
|
||||
return {
|
||||
id: campaign.id || 'unknown',
|
||||
name: 'Error Processing Campaign',
|
||||
stats: {}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export class MetricsService {
|
||||
constructor(apiKey, apiRevision) {
|
||||
this.apiKey = apiKey;
|
||||
this.apiRevision = apiRevision;
|
||||
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||
}
|
||||
async getMetrics() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/metrics/`, {
|
||||
headers: {
|
||||
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||
'revision': this.apiRevision,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('[MetricsService] API Error:', errorData);
|
||||
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Sort the results by name before returning
|
||||
if (data.data) {
|
||||
data.data.sort((a, b) => a.attributes.name.localeCompare(b.attributes.name));
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[MetricsService] Error fetching metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Klaviyo cache wrapper. Was a self-instantiating ioredis client per service in
|
||||
// the standalone klaviyo-server; now accepts an injected client so the merged
|
||||
// dashboard-server shares one connection across all vendors (Phase 4).
|
||||
//
|
||||
// Public surface kept identical to the original so the ~3K LOC of klaviyo
|
||||
// service code (events/campaigns/reporting) needs no other changes:
|
||||
// - get(key)
|
||||
// - set(key, data, ttl)
|
||||
// - _getCacheKey(type, params)
|
||||
// - _getTTL(timeRange)
|
||||
// - getEventData(type, params) / cacheEventData(type, params, data)
|
||||
// - clearCache(params)
|
||||
//
|
||||
// Reads short-circuit to null when the client isn't ready; writes are no-ops.
|
||||
// Same "Redis hiccup → fall through to upstream" behavior as before.
|
||||
|
||||
import { TimeManager } from '../../utils/time.utils.js';
|
||||
|
||||
export class RedisService {
|
||||
constructor(redis) {
|
||||
if (!redis) {
|
||||
throw new Error('RedisService requires an ioredis client (Phase 4: injected, no longer self-constructed)');
|
||||
}
|
||||
this.client = redis;
|
||||
this.timeManager = new TimeManager();
|
||||
this.DEFAULT_TTL = 5 * 60;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
// ioredis: 'wait' | 'reconnecting' | 'connecting' | 'connect' | 'ready' | 'close' | 'end'
|
||||
return this.client.status === 'ready' || this.client.status === 'connect';
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
if (!this.isConnected) return null;
|
||||
try {
|
||||
const data = await this.client.get(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.error('[RedisService] Error getting data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key, data, ttl = this.DEFAULT_TTL) {
|
||||
if (!this.isConnected) return;
|
||||
try {
|
||||
await this.client.setex(key, ttl, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('[RedisService] Error setting data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
_getCacheKey(type, params = {}) {
|
||||
const {
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
metricId,
|
||||
metric,
|
||||
daily,
|
||||
cacheKey,
|
||||
isPreviousPeriod,
|
||||
customFilters,
|
||||
} = params;
|
||||
|
||||
let key = `klaviyo:${type}`;
|
||||
|
||||
if (type === 'stats:details') {
|
||||
key += `:${metric || 'all'}`;
|
||||
if (daily) key += ':daily';
|
||||
if (customFilters?.length) {
|
||||
const filterHash = customFilters.join('').replace(/[^a-zA-Z0-9]/g, '');
|
||||
key += `:${filterHash}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
key += `:${cacheKey}`;
|
||||
} else if (timeRange) {
|
||||
key += `:${timeRange}`;
|
||||
if (metricId) key += `:${metricId}`;
|
||||
if (isPreviousPeriod) key += ':prev';
|
||||
} else if (startDate && endDate) {
|
||||
key += `:custom:${startDate}:${endDate}`;
|
||||
if (metricId) key += `:${metricId}`;
|
||||
if (isPreviousPeriod) key += ':prev';
|
||||
}
|
||||
|
||||
if (['pre_orders', 'local_pickup', 'on_hold'].includes(metric)) {
|
||||
key += `:${metric}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
_getTTL(timeRange) {
|
||||
const TTL_MAP = {
|
||||
today: 2 * 60,
|
||||
yesterday: 30 * 60,
|
||||
thisWeek: 5 * 60,
|
||||
lastWeek: 60 * 60,
|
||||
thisMonth: 10 * 60,
|
||||
lastMonth: 2 * 60 * 60,
|
||||
last7days: 5 * 60,
|
||||
last30days: 15 * 60,
|
||||
custom: 15 * 60,
|
||||
};
|
||||
return TTL_MAP[timeRange] || this.DEFAULT_TTL;
|
||||
}
|
||||
|
||||
async getEventData(type, params) {
|
||||
if (!this.isConnected) return null;
|
||||
try {
|
||||
const baseKey = this._getCacheKey('events', params);
|
||||
return await this.get(`${baseKey}:${type}`);
|
||||
} catch (error) {
|
||||
console.error('[RedisService] Error getting event data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async cacheEventData(type, params, data) {
|
||||
if (!this.isConnected) return;
|
||||
try {
|
||||
const ttl = this._getTTL(params.timeRange);
|
||||
const baseKey = this._getCacheKey('events', params);
|
||||
await this.set(`${baseKey}:${type}`, data, ttl);
|
||||
} catch (error) {
|
||||
console.error('[RedisService] Error caching event data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(params = {}) {
|
||||
if (!this.isConnected) return;
|
||||
try {
|
||||
const pattern = this._getCacheKey('events', params) + '*';
|
||||
const keys = await this.client.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[RedisService] Error clearing cache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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, redis) {
|
||||
this.apiKey = apiKey;
|
||||
this.apiRevision = apiRevision;
|
||||
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||
this.timeManager = new TimeManager();
|
||||
this.redisService = new RedisService(redis);
|
||||
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');
|
||||
|
||||
// Determine which channels to fetch based on params
|
||||
const channelsToFetch = params.channel === 'all' || !params.channel
|
||||
? ['email', 'sms']
|
||||
: [params.channel];
|
||||
|
||||
const allResults = [];
|
||||
|
||||
// Fetch each channel
|
||||
for (const channel of channelsToFetch) {
|
||||
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,"${channel}")`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 ${channel} 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) || [];
|
||||
|
||||
if (campaignIds.length > 0) {
|
||||
// Get campaign details including send time and subject lines
|
||||
const campaignDetails = await this.getCampaignDetails(campaignIds);
|
||||
|
||||
// Process results for this channel
|
||||
const channelResults = reportData.data.attributes.results.map(result => {
|
||||
const campaignId = result.groupings.campaign_id;
|
||||
const details = campaignDetails.find(detail => detail.id === campaignId);
|
||||
|
||||
return {
|
||||
id: campaignId,
|
||||
name: details.attributes.name,
|
||||
subject: details.attributes.subject,
|
||||
send_time: details.attributes.send_time,
|
||||
channel: channel, // Use the channel we're currently processing
|
||||
stats: {
|
||||
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
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
allResults.push(...channelResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort all results by date
|
||||
const enrichedData = {
|
||||
data: allResults.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');
|
||||
|
||||
console.log('[ReportingService] Campaign details for ID:', campaignId, {
|
||||
send_channel: data.data.attributes.send_channel,
|
||||
raw_attributes: data.data.attributes
|
||||
});
|
||||
|
||||
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,
|
||||
send_channel: data.data.attributes.send_channel || 'email'
|
||||
}
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user