Phase 4 + 6

This commit is contained in:
2026-05-24 09:13:39 -04:00
parent 4be0f877fa
commit cf71cc4dec
65 changed files with 4035 additions and 9121 deletions
@@ -0,0 +1,195 @@
// Google Analytics (GA4) service — ESM conversion of google-server/services/analytics.service.js.
// Phase 4: accepts injected ioredis client (was self-constructing node-redis v4 before).
// node-redis v4 set syntax `{ EX: 300 }` is translated to ioredis `setex(key, 300, val)`.
import { BetaAnalyticsDataClient } from '@google-analytics/data';
const CACHE_DURATIONS = {
REALTIME_BASIC: 60,
REALTIME_DETAILED: 300,
BASIC_METRICS: 3600,
USER_BEHAVIOR: 3600,
};
export class AnalyticsService {
constructor(redis) {
if (!redis) {
throw new Error('AnalyticsService requires an ioredis client (Phase 4: injected)');
}
this.redis = redis;
const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON;
this.analyticsClient = new BetaAnalyticsDataClient({
credentials: typeof credentials === 'string' ? JSON.parse(credentials) : credentials,
});
this.propertyId = process.env.GA_PROPERTY_ID;
}
get _redisReady() {
return this.redis.status === 'ready' || this.redis.status === 'connect';
}
async _cacheGet(key) {
if (!this._redisReady) return null;
try {
const raw = await this.redis.get(key);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.warn('[AnalyticsService] cache get failed:', err.message);
return null;
}
}
async _cacheSet(key, value, ttlSec) {
if (!this._redisReady) return;
try {
await this.redis.setex(key, ttlSec, JSON.stringify(value));
} catch (err) {
console.warn('[AnalyticsService] cache set failed:', err.message);
}
}
async getBasicMetrics(startDate = '7daysAgo') {
const cacheKey = `analytics:basic_metrics:${startDate}`;
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const [response] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate, endDate: 'today' }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'activeUsers' },
{ name: 'newUsers' },
{ name: 'averageSessionDuration' },
{ name: 'screenPageViews' },
{ name: 'bounceRate' },
{ name: 'conversions' },
],
returnPropertyQuota: true,
});
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.BASIC_METRICS);
return response;
}
async getRealTimeBasicData() {
const cacheKey = 'analytics:realtime:basic';
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const [userResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
returnPropertyQuota: true,
});
const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
metrics: [{ name: 'activeUsers' }],
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }],
});
const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'minutesAgo' }],
metrics: [{ name: 'activeUsers' }],
});
const response = {
userResponse,
fiveMinResponse,
timeSeriesResponse,
quotaInfo: {
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
daily: userResponse.propertyQuota.tokensPerDay,
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour,
},
};
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.REALTIME_BASIC);
return response;
}
async getRealTimeDetailedData() {
const cacheKey = 'analytics:realtime:detailed';
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const [pageResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'unifiedScreenName' }],
metrics: [{ name: 'screenPageViews' }],
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 25,
});
const [eventResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'eventName' }],
metrics: [{ name: 'eventCount' }],
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
limit: 25,
});
const [deviceResponse] = await this.analyticsClient.runRealtimeReport({
property: `properties/${this.propertyId}`,
dimensions: [{ name: 'deviceCategory' }],
metrics: [{ name: 'activeUsers' }],
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
limit: 10,
returnPropertyQuota: true,
});
const response = {
pageResponse,
eventResponse,
sourceResponse: deviceResponse,
};
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.REALTIME_DETAILED);
return response;
}
async getUserBehavior(timeRange = '30') {
const cacheKey = `analytics:user_behavior:${timeRange}`;
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const [pageResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'pagePath' }],
metrics: [
{ name: 'screenPageViews' },
{ name: 'averageSessionDuration' },
{ name: 'bounceRate' },
{ name: 'sessions' },
],
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 25,
});
const [deviceResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'deviceCategory' }],
metrics: [{ name: 'screenPageViews' }, { name: 'sessions' }],
});
const [sourceResponse] = await this.analyticsClient.runReport({
property: `properties/${this.propertyId}`,
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
dimensions: [{ name: 'sessionSource' }],
metrics: [{ name: 'sessions' }, { name: 'conversions' }],
orderBy: [{ metric: { metricName: 'sessions' }, desc: true }],
limit: 25,
returnPropertyQuota: true,
});
const response = { pageResponse, deviceResponse, sourceResponse };
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.USER_BEHAVIOR);
return response;
}
}
@@ -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;
}
}
@@ -0,0 +1,104 @@
// Meta (Facebook Ads) service — ESM conversion of meta-server/services/meta.service.js.
// No Redis caching (matches the original — Meta calls are cheap-enough; reach/spend
// rolls over once per request). Uses axios.
import axios from 'axios';
function getConfig() {
const version = process.env.META_API_VERSION || 'v21.0';
return {
baseUrl: `https://graph.facebook.com/${version}`,
accessToken: process.env.META_ACCESS_TOKEN,
adAccountId: process.env.META_AD_ACCOUNT_ID,
};
}
async function metaApiRequest(endpoint, params = {}) {
const { baseUrl, accessToken } = getConfig();
try {
const response = await axios.get(`${baseUrl}/${endpoint}`, {
params: {
access_token: accessToken,
time_zone: 'America/New_York',
...params,
},
});
return response.data;
} catch (error) {
console.error('Meta API Error:', {
message: error.message,
response: error.response?.data,
endpoint,
});
throw error;
}
}
export async function fetchCampaigns(since, until) {
const { adAccountId } = getConfig();
const campaigns = await metaApiRequest(`act_${adAccountId}/campaigns`, {
fields: [
'id',
'name',
'status',
'objective',
'daily_budget',
'lifetime_budget',
'adsets{daily_budget,lifetime_budget}',
`insights.time_range({'since':'${since}','until':'${until}'}).level(campaign){
spend,
impressions,
clicks,
ctr,
reach,
frequency,
cpm,
cpc,
actions,
action_values,
cost_per_action_type
}`,
].join(','),
limit: 100,
});
return campaigns.data.filter((c) => c.insights?.data?.[0]?.spend > 0);
}
export async function fetchAccountInsights(since, until) {
const { adAccountId } = getConfig();
const accountInsights = await metaApiRequest(`act_${adAccountId}/insights`, {
fields: 'reach,spend,impressions,clicks,ctr,cpm,actions,action_values',
time_range: JSON.stringify({ since, until }),
});
return accountInsights.data[0] || null;
}
export async function updateCampaignBudget(campaignId, budget) {
const { baseUrl, accessToken } = getConfig();
try {
const response = await axios.post(`${baseUrl}/${campaignId}`, {
access_token: accessToken,
daily_budget: budget * 100, // dollars → cents
});
return response.data;
} catch (error) {
console.error('Update campaign budget error:', error);
throw error;
}
}
export async function updateCampaignStatus(campaignId, action) {
const { baseUrl, accessToken } = getConfig();
try {
const status = action === 'pause' ? 'PAUSED' : 'ACTIVE';
const response = await axios.post(`${baseUrl}/${campaignId}`, {
access_token: accessToken,
status,
});
return response.data;
} catch (error) {
console.error('Update campaign status error:', error);
throw error;
}
}
@@ -0,0 +1,80 @@
// Typeform service — ESM conversion of typeform-server/services/typeform.service.js.
// Phase 4: accepts injected ioredis client. node-redis v4 set syntax `{ EX: 300 }`
// translated to ioredis `setex(key, 300, val)`.
import axios from 'axios';
export class TypeformService {
constructor(redis) {
if (!redis) {
throw new Error('TypeformService requires an ioredis client (Phase 4: injected)');
}
this.redis = redis;
const token = process.env.TYPEFORM_ACCESS_TOKEN;
if (!token) {
console.warn('[Typeform] TYPEFORM_ACCESS_TOKEN not set — all calls will 401');
}
this.apiClient = axios.create({
baseURL: 'https://api.typeform.com',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
}
get _redisReady() {
return this.redis.status === 'ready' || this.redis.status === 'connect';
}
async _cacheGet(key) {
if (!this._redisReady) return null;
try {
const raw = await this.redis.get(key);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.warn('[Typeform] cache get failed:', err.message);
return null;
}
}
async _cacheSet(key, value, ttlSec) {
if (!this._redisReady) return;
try {
await this.redis.setex(key, ttlSec, JSON.stringify(value));
} catch (err) {
console.warn('[Typeform] cache set failed:', err.message);
}
}
async getFormResponses(formId, params = {}) {
const cacheKey = `typeform:responses:${formId}:${JSON.stringify(params)}`;
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const response = await this.apiClient.get(`/forms/${formId}/responses`, { params });
const data = response.data;
await this._cacheSet(cacheKey, data, 300);
return data;
}
async getFormInsights(formId) {
const cacheKey = `typeform:insights:${formId}`;
const cached = await this._cacheGet(cacheKey);
if (cached) return cached;
const response = await this.apiClient.get(`/insights/${formId}/summary`);
const data = response.data;
await this._cacheSet(cacheKey, data, 300);
return data;
}
async getFormResponsesWithFilters(formId, { since, until, pageSize = 25, ...otherParams } = {}) {
const params = { page_size: pageSize, ...otherParams };
if (since) params.since = new Date(since).toISOString();
if (until) params.until = new Date(until).toISOString();
return this.getFormResponses(formId, params);
}
}