Overdue initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.env
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"folder-color.pathColors": [
|
||||||
|
{
|
||||||
|
"folderPath": "Dev/dashboard/",
|
||||||
|
"badge": "🔵"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"folderPath": "Dev/dashboard-server/",
|
||||||
|
"badge": "🟣"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
# Caching Server Configuration
|
||||||
|
PORT=3010
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
MONGODB_URI=mongodb://dashboard_user:WDRFWiGXEeaC6aAyUKuT@localhost:27017/dashboard?authSource=dashboard
|
||||||
|
REDIS_URL=redis://:Wgj32YXxxVLtPZoVzUnP@localhost:6379
|
||||||
|
|
||||||
|
# Gorgias
|
||||||
|
GORGIAS_API_USERNAME=matt@acherryontop.com
|
||||||
|
GORGIAS_API_PASSWORD=d2ed0d23d2a7bf11a633a12fb260769f4e4a970d440693e7d64b8d2223fa6503
|
||||||
|
|
||||||
|
# GA4 credentials
|
||||||
|
GA_PROPERTY_ID=281045851
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS_JSON={"type": "service_account","project_id": "acot-stats","private_key_id": "259d1fd9864efbfa38b8ba02fdd74dc008ace3c5","private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5Y6foai8WF98k\nIA0yLn94Y3lmDYlyvI9xL2YqSZSyvgK35wdWRTIaEvHKdiUWuYi3ZPdkYmz1OYiV\njVfR2g+mFpA7MI/JMwyGWwjnV4WW2q6INfgi/PvHlbP3LyyQo0B8CvAY0CHqrpDs\nlJQhAkqmteU24dqcdZoV3vM8JMsDiXm44DqwXsEfWibKv4i0mWNkwiEQr0yImHwb\nbjgclwVLLi5kdM2+49PXr47LCODdL+xmX0uSdgSG6XYqEIVsEOXIUJKzqUe036b/\nEFQ0BxWdJBWs/MYOapn/NNv+Mts+am2ipUuIcgPbOut4xa2Fkky93WnJf0tB+VJP\njFnyZJhdAgMBAAECggEAC980Cp/4zvSNZMNWr6l8ST8u2thavnRmcoGYtx7ffQjK\nT3Dl2TefgJLzqpr2lLt3OVint7p5LsUAmE8lBLpu+RxbH9HkIKbPvQTfD5gyZQQx\nBruqCGzkn2st9fzZNj6gwQYe9P/TGYkUnR8wqI0nLwDZTQful3QNKixiWC4lAAoK\nqdd6H++pqjVUiTqgFwFD3zBAhO0Lp8m/c5vTRT5kxi0wCTK66FaaGLr2OwZHcohp\nE8rEcTZ5kaJzBwqEz522R6ufQqN1Swoq4K6Ul3aAc59539VdrLNs++/eRH38MMVq\n5UTwBrH+zIkXIYv4mtGpR1NWGO2bZ652GzGXNEXcQQKBgQD9WsMmioIeWR9P9I0r\nIY+yyxz1EyscutUtnOtROT36OxokrzQaAKDz/OC3jVnhZSkzG6RcmmK/AJrcU+2m\n1L4mZGfF3DdeTqtK/KkNzGs9yRPDkbb/MF0wgtcvfE8tJH/suiDJKQNsjeaQIQW3\n4NvDxs0w60m9r9tk1CQau94ovQKBgQC7UzeA0mDSxIB5agGbvnzaJJTvAFvnCvhz\nu3ZakTlNecAHu4eOMc0+OCHFPLJlLL4b0oraOxZIszX9BTlgcstBmTUk03TibNsS\nsDiImHFC4hE5x6EPdifnkVFUXPMZ/eF0mHUPBEn41ipw1hoLfl6W+aYW9QUxBMWA\nzdMH4rg4IQKBgQCFcMaUiCNchKhfXnj0HKspCp3n3v64FReu/JVcpH+mSnbMl5Mj\nlu0vVSOuyb5rXvLCPm7lb1NPMqxeG75yPl8grYWSyxhGjbzetBD+eYqKclv8h8UQ\nx5JtuJxKIHk7V5whPS+DhByPknW7uAjg/ogBp7XvbB3c0MEHbEzP3991KQKBgC+a\n610Kmd6WX4v7e6Mn2rTZXRwL/E8QA6nttxs3Etf0m++bIczqLR2lyDdGwJNjtoB9\nlhn1sCkTmiHOBRHUuoDWPaI5NtggD+CE9ikIjKgRqY0EhZLXVTbNQFzvLjypv3UR\nFZaWYXIigzCfyIipOcKmeSYWaJZXfxXHuNylKmnhAoGAFa84AuOOGUr+pEvtUzIr\nvBKu1mnQbbsLEhgf3Tw88K3sO5OlguAwBEvD4eitj/aU5u2vJJhFa67cuERLsZru\n0sjtQwP6CJbWF4uaH0Hso4KQvnwl4BfdKwUncqoKtHrQiuGMvr5P5G941+Ax8brE\nJlC2e/RPUQKxScpK3nNK9mc=\n-----END PRIVATE KEY-----\n","client_email": "matt-dashboard@acot-stats.iam.gserviceaccount.com","client_id": "106112731322970982546","auth_uri": "https://accounts.google.com/o/oauth2/auth","token_uri": "https://oauth2.googleapis.com/token","auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/matt-dashboard%40acot-stats.iam.gserviceaccount.com","universe_domain": "googleapis.com"}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_MAX_SIZE=10m
|
||||||
|
LOG_MAX_FILES=5
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
|||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
AIRCALL_PORT=3002
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Aircall API Credentials
|
||||||
|
AIRCALL_API_ID=your_aircall_api_id
|
||||||
|
AIRCALL_API_TOKEN=your_aircall_api_token
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/dashboard
|
||||||
|
MONGODB_DB=dashboard
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Service Configuration
|
||||||
|
TIMEZONE=America/New_York
|
||||||
|
DAY_STARTS_AT=1 # Business day starts at 1 AM ET
|
||||||
|
|
||||||
|
# Optional Settings
|
||||||
|
REDIS_TTL=300 # Cache TTL in seconds (5 minutes)
|
||||||
|
COLLECTION_NAME=aircall_daily_data
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Aircall Server
|
||||||
|
|
||||||
|
A standalone server for handling Aircall metrics and data processing.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up environment variables:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
Then edit `.env` with your configuration.
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
- `AIRCALL_API_ID`: Your Aircall API ID
|
||||||
|
- `AIRCALL_API_TOKEN`: Your Aircall API Token
|
||||||
|
- `MONGODB_URI`: MongoDB connection string
|
||||||
|
- `REDIS_URL`: Redis connection string
|
||||||
|
- `AIRCALL_PORT`: Server port (default: 3002)
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
Using PM2:
|
||||||
|
```bash
|
||||||
|
pm2 start ecosystem.config.js --env production
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api/aircall/metrics/:timeRange
|
||||||
|
Get Aircall metrics for a specific time range.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- `timeRange`: One of ['today', 'yesterday', 'last7days', 'last30days', 'last90days']
|
||||||
|
|
||||||
|
### GET /api/aircall/health
|
||||||
|
Get server health status.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The server uses:
|
||||||
|
- Express.js for the API
|
||||||
|
- MongoDB for data storage
|
||||||
|
- Redis for caching
|
||||||
|
- Winston for logging
|
||||||
+1882
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "aircall-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Aircall metrics server",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mongodb": "^6.3.0",
|
||||||
|
"redis": "^4.6.11",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { createRoutes } from './src/routes/index.js';
|
||||||
|
import { aircallConfig } from './src/config/aircall.config.js';
|
||||||
|
import { connectMongoDB } from './src/utils/db.js';
|
||||||
|
import { createRedisClient } from './src/utils/redis.js';
|
||||||
|
import { createLogger } from './src/utils/logger.js';
|
||||||
|
|
||||||
|
// Get directory name in ES modules
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables from the correct path
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
const requiredEnvVars = ['AIRCALL_API_ID', 'AIRCALL_API_TOKEN', 'MONGODB_URI', 'REDIS_URL'];
|
||||||
|
const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]);
|
||||||
|
|
||||||
|
if (missingEnvVars.length > 0) {
|
||||||
|
console.error('Missing required environment variables:', missingEnvVars);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.AIRCALL_PORT || 3002;
|
||||||
|
const logger = createLogger('aircall-server');
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Connect to databases
|
||||||
|
let mongodb;
|
||||||
|
let redis;
|
||||||
|
|
||||||
|
async function initializeServer() {
|
||||||
|
try {
|
||||||
|
// Connect to MongoDB
|
||||||
|
mongodb = await connectMongoDB();
|
||||||
|
logger.info('Connected to MongoDB');
|
||||||
|
|
||||||
|
// Connect to Redis
|
||||||
|
redis = await createRedisClient();
|
||||||
|
logger.info('Connected to Redis');
|
||||||
|
|
||||||
|
// Initialize configs with database connections
|
||||||
|
const configs = {
|
||||||
|
aircall: {
|
||||||
|
...aircallConfig,
|
||||||
|
mongodb,
|
||||||
|
redis,
|
||||||
|
logger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize routes
|
||||||
|
const routes = createRoutes(configs, logger);
|
||||||
|
app.use('/api', routes);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error('Server error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info(`Aircall server listening on port ${port}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeServer();
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
export const aircallConfig = {
|
||||||
|
serviceName: 'aircall',
|
||||||
|
apiId: process.env.AIRCALL_API_ID,
|
||||||
|
apiToken: process.env.AIRCALL_API_TOKEN,
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
dayStartsAt: 1,
|
||||||
|
storeHistory: true,
|
||||||
|
collection: 'aircall_daily_data',
|
||||||
|
redisTTL: 300, // 5 minutes cache for current day
|
||||||
|
endpoints: {
|
||||||
|
metrics: {
|
||||||
|
ttl: 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { AircallService } from '../services/aircall/AircallService.js';
|
||||||
|
|
||||||
|
export const createAircallRoutes = (config, logger) => {
|
||||||
|
const router = express.Router();
|
||||||
|
const aircallService = new AircallService(config);
|
||||||
|
|
||||||
|
router.get('/metrics/:timeRange?', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange = 'today' } = req.params;
|
||||||
|
const allowedRanges = ['today', 'yesterday', 'last7days', 'last30days', 'last90days'];
|
||||||
|
|
||||||
|
if (!allowedRanges.includes(timeRange)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid time range',
|
||||||
|
allowedRanges
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await aircallService.getMetrics(timeRange);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...metrics,
|
||||||
|
_meta: {
|
||||||
|
timeRange,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
dataPoints: metrics.daily_data?.length || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching Aircall metrics:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch Aircall metrics',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
const mongoConnected = !!aircallService.mongodb?.db;
|
||||||
|
const redisConnected = !!aircallService.redis?.isOpen;
|
||||||
|
|
||||||
|
const health = {
|
||||||
|
status: mongoConnected && redisConnected ? 'ok' : 'degraded',
|
||||||
|
service: 'aircall',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
connections: {
|
||||||
|
mongodb: mongoConnected,
|
||||||
|
redis: redisConnected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { createAircallRoutes } from './aircall.routes.js';
|
||||||
|
|
||||||
|
export const createRoutes = (configs, logger) => {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Mount Aircall routes
|
||||||
|
router.use('/aircall', createAircallRoutes(configs.aircall, logger));
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
const services = req.services || {};
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date(),
|
||||||
|
services: {
|
||||||
|
redis: services.redis?.isReady || false,
|
||||||
|
mongodb: services.mongo?.readyState === 1 || false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-all 404 handler
|
||||||
|
router.use('*', (req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `Route ${req.originalUrl} not found`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,298 @@
|
|||||||
|
import { DataManager } from "../base/DataManager.js";
|
||||||
|
|
||||||
|
export class AircallDataManager extends DataManager {
|
||||||
|
constructor(mongodb, redis, timeManager) {
|
||||||
|
const options = {
|
||||||
|
collection: "aircall_daily_data",
|
||||||
|
redisTTL: 300 // 5 minutes cache
|
||||||
|
};
|
||||||
|
super(mongodb, redis, timeManager, options);
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDate(d) {
|
||||||
|
if (d instanceof Date) return d;
|
||||||
|
if (typeof d === 'string') return new Date(d);
|
||||||
|
if (typeof d === 'number') return new Date(d);
|
||||||
|
console.error('Invalid date value:', d);
|
||||||
|
return new Date(); // fallback to current date
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeHistoricalPeriod(start, end, calls) {
|
||||||
|
if (!this.mongodb) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Array.isArray(calls)) {
|
||||||
|
console.error("Invalid calls data:", calls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group calls by true day boundaries using TimeManager
|
||||||
|
const dailyCallsMap = new Map();
|
||||||
|
|
||||||
|
calls.forEach((call) => {
|
||||||
|
try {
|
||||||
|
const timestamp = call.started_at * 1000; // Convert to milliseconds
|
||||||
|
const callDate = this.ensureDate(timestamp);
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(callDate);
|
||||||
|
const dayKey = dayBounds.start.toISOString();
|
||||||
|
|
||||||
|
if (!dailyCallsMap.has(dayKey)) {
|
||||||
|
dailyCallsMap.set(dayKey, {
|
||||||
|
date: dayBounds.start,
|
||||||
|
calls: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dailyCallsMap.get(dayKey).calls.push(call);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error processing call:', err, call);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Iterate over each day in the period using day boundaries
|
||||||
|
const dates = [];
|
||||||
|
let currentDate = this.ensureDate(start);
|
||||||
|
const endDate = this.ensureDate(end);
|
||||||
|
|
||||||
|
while (currentDate < endDate) {
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(currentDate);
|
||||||
|
dates.push(dayBounds.start);
|
||||||
|
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const date of dates) {
|
||||||
|
try {
|
||||||
|
const dateKey = date.toISOString();
|
||||||
|
const dayData = dailyCallsMap.get(dateKey);
|
||||||
|
const dayCalls = dayData ? dayData.calls : [];
|
||||||
|
|
||||||
|
// Process calls for this day using the same processing logic
|
||||||
|
const metrics = this.processCallData(dayCalls);
|
||||||
|
|
||||||
|
// Insert a daily_data record for this day
|
||||||
|
metrics.daily_data = [
|
||||||
|
{
|
||||||
|
date: date.toISOString().split("T")[0],
|
||||||
|
inbound: metrics.by_direction.inbound,
|
||||||
|
outbound: metrics.by_direction.outbound,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Store this day's processed data as historical
|
||||||
|
await this.storeHistoricalDay(date, metrics);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error processing date:', err, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing historical period:", error, error.stack);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processCallData(calls) {
|
||||||
|
// If calls is already processed (has total, by_direction, etc.), return it
|
||||||
|
if (calls && calls.total !== undefined) {
|
||||||
|
console.log('Data already processed:', {
|
||||||
|
total: calls.total,
|
||||||
|
by_direction: calls.by_direction
|
||||||
|
});
|
||||||
|
// Return a clean copy of the processed data
|
||||||
|
return {
|
||||||
|
total: calls.total,
|
||||||
|
by_direction: calls.by_direction,
|
||||||
|
by_status: calls.by_status,
|
||||||
|
by_missed_reason: calls.by_missed_reason,
|
||||||
|
by_hour: calls.by_hour,
|
||||||
|
by_users: calls.by_users,
|
||||||
|
daily_data: calls.daily_data,
|
||||||
|
duration_distribution: calls.duration_distribution,
|
||||||
|
average_duration: calls.average_duration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Processing raw calls:', {
|
||||||
|
count: calls.length,
|
||||||
|
sample: calls.length > 0 ? {
|
||||||
|
id: calls[0].id,
|
||||||
|
direction: calls[0].direction,
|
||||||
|
status: calls[0].status
|
||||||
|
} : null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process raw calls
|
||||||
|
const metrics = {
|
||||||
|
total: calls.length,
|
||||||
|
by_direction: { inbound: 0, outbound: 0 },
|
||||||
|
by_status: { answered: 0, missed: 0 },
|
||||||
|
by_missed_reason: {},
|
||||||
|
by_hour: Array(24).fill(0),
|
||||||
|
by_users: {},
|
||||||
|
daily_data: [],
|
||||||
|
duration_distribution: [
|
||||||
|
{ range: "0-1m", count: 0 },
|
||||||
|
{ range: "1-5m", count: 0 },
|
||||||
|
{ range: "5-15m", count: 0 },
|
||||||
|
{ range: "15-30m", count: 0 },
|
||||||
|
{ range: "30m+", count: 0 },
|
||||||
|
],
|
||||||
|
average_duration: 0,
|
||||||
|
total_duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group calls by date for daily data
|
||||||
|
const dailyCallsMap = new Map();
|
||||||
|
|
||||||
|
calls.forEach((call) => {
|
||||||
|
try {
|
||||||
|
// Direction metrics
|
||||||
|
metrics.by_direction[call.direction]++;
|
||||||
|
|
||||||
|
// Get call date and hour using TimeManager
|
||||||
|
const timestamp = call.started_at * 1000; // Convert to milliseconds
|
||||||
|
const callDate = this.ensureDate(timestamp);
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(callDate);
|
||||||
|
const dayKey = dayBounds.start.toISOString().split("T")[0];
|
||||||
|
const hour = callDate.getHours();
|
||||||
|
metrics.by_hour[hour]++;
|
||||||
|
|
||||||
|
// Status and duration metrics
|
||||||
|
if (call.answered_at) {
|
||||||
|
metrics.by_status.answered++;
|
||||||
|
const duration = call.ended_at - call.answered_at;
|
||||||
|
metrics.total_duration += duration;
|
||||||
|
|
||||||
|
// Duration distribution
|
||||||
|
if (duration <= 60) {
|
||||||
|
metrics.duration_distribution[0].count++;
|
||||||
|
} else if (duration <= 300) {
|
||||||
|
metrics.duration_distribution[1].count++;
|
||||||
|
} else if (duration <= 900) {
|
||||||
|
metrics.duration_distribution[2].count++;
|
||||||
|
} else if (duration <= 1800) {
|
||||||
|
metrics.duration_distribution[3].count++;
|
||||||
|
} else {
|
||||||
|
metrics.duration_distribution[4].count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track user performance
|
||||||
|
if (call.user) {
|
||||||
|
const userId = call.user.id;
|
||||||
|
if (!metrics.by_users[userId]) {
|
||||||
|
metrics.by_users[userId] = {
|
||||||
|
id: userId,
|
||||||
|
name: call.user.name,
|
||||||
|
total: 0,
|
||||||
|
answered: 0,
|
||||||
|
missed: 0,
|
||||||
|
total_duration: 0,
|
||||||
|
average_duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
metrics.by_users[userId].total++;
|
||||||
|
metrics.by_users[userId].answered++;
|
||||||
|
metrics.by_users[userId].total_duration += duration;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
metrics.by_status.missed++;
|
||||||
|
if (call.missed_call_reason) {
|
||||||
|
metrics.by_missed_reason[call.missed_call_reason] =
|
||||||
|
(metrics.by_missed_reason[call.missed_call_reason] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track missed calls by user
|
||||||
|
if (call.user) {
|
||||||
|
const userId = call.user.id;
|
||||||
|
if (!metrics.by_users[userId]) {
|
||||||
|
metrics.by_users[userId] = {
|
||||||
|
id: userId,
|
||||||
|
name: call.user.name,
|
||||||
|
total: 0,
|
||||||
|
answered: 0,
|
||||||
|
missed: 0,
|
||||||
|
total_duration: 0,
|
||||||
|
average_duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
metrics.by_users[userId].total++;
|
||||||
|
metrics.by_users[userId].missed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by date for daily data
|
||||||
|
if (!dailyCallsMap.has(dayKey)) {
|
||||||
|
dailyCallsMap.set(dayKey, { date: dayKey, inbound: 0, outbound: 0 });
|
||||||
|
}
|
||||||
|
dailyCallsMap.get(dayKey)[call.direction]++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error processing call:', err, call);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate average durations for users
|
||||||
|
Object.values(metrics.by_users).forEach((user) => {
|
||||||
|
if (user.answered > 0) {
|
||||||
|
user.average_duration = Math.round(user.total_duration / user.answered);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate global average duration
|
||||||
|
if (metrics.by_status.answered > 0) {
|
||||||
|
metrics.average_duration = Math.round(
|
||||||
|
metrics.total_duration / metrics.by_status.answered
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert daily data map to sorted array
|
||||||
|
metrics.daily_data = Array.from(dailyCallsMap.values()).sort((a, b) =>
|
||||||
|
a.date.localeCompare(b.date)
|
||||||
|
);
|
||||||
|
|
||||||
|
delete metrics.total_duration;
|
||||||
|
|
||||||
|
console.log('Processed metrics:', {
|
||||||
|
total: metrics.total,
|
||||||
|
by_direction: metrics.by_direction,
|
||||||
|
by_status: metrics.by_status,
|
||||||
|
daily_data_count: metrics.daily_data.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeHistoricalDay(date, data) {
|
||||||
|
if (!this.mongodb) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = this.mongodb.collection(this.options.collection);
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(this.ensureDate(date));
|
||||||
|
|
||||||
|
// Ensure consistent data structure with metrics nested in data field
|
||||||
|
const document = {
|
||||||
|
date: dayBounds.start,
|
||||||
|
data: {
|
||||||
|
total: data.total,
|
||||||
|
by_direction: data.by_direction,
|
||||||
|
by_status: data.by_status,
|
||||||
|
by_missed_reason: data.by_missed_reason,
|
||||||
|
by_hour: data.by_hour,
|
||||||
|
by_users: data.by_users,
|
||||||
|
daily_data: data.daily_data,
|
||||||
|
duration_distribution: data.duration_distribution,
|
||||||
|
average_duration: data.average_duration
|
||||||
|
},
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
await collection.updateOne(
|
||||||
|
{ date: dayBounds.start },
|
||||||
|
{ $set: document },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error storing historical day:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
import { BaseService } from "../base/BaseService.js";
|
||||||
|
import { AircallDataManager } from "./AircallDataManager.js";
|
||||||
|
|
||||||
|
export class AircallService extends BaseService {
|
||||||
|
constructor(config) {
|
||||||
|
super(config);
|
||||||
|
this.baseUrl = "https://api.aircall.io/v1";
|
||||||
|
console.log('Initializing Aircall service with credentials:', {
|
||||||
|
apiId: config.apiId ? 'present' : 'missing',
|
||||||
|
apiToken: config.apiToken ? 'present' : 'missing'
|
||||||
|
});
|
||||||
|
this.auth = Buffer.from(`${config.apiId}:${config.apiToken}`).toString(
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
|
this.dataManager = new AircallDataManager(
|
||||||
|
this.mongodb,
|
||||||
|
this.redis,
|
||||||
|
this.timeManager
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config.apiId || !config.apiToken) {
|
||||||
|
throw new Error("Aircall API credentials are required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(timeRange) {
|
||||||
|
const dateRange = await this.timeManager.getDateRange(timeRange);
|
||||||
|
console.log('Fetching metrics for date range:', {
|
||||||
|
start: dateRange.start.toISOString(),
|
||||||
|
end: dateRange.end.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.dataManager.getData(dateRange, async (range) => {
|
||||||
|
const calls = await this.fetchAllCalls(range.start, range.end);
|
||||||
|
console.log('Fetched calls:', {
|
||||||
|
count: calls.length,
|
||||||
|
sample: calls.length > 0 ? calls[0] : null
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllCalls(start, end) {
|
||||||
|
try {
|
||||||
|
let allCalls = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
let totalPages = null;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await this.makeRequest("/calls", {
|
||||||
|
from: Math.floor(start.getTime() / 1000),
|
||||||
|
to: Math.floor(end.getTime() / 1000),
|
||||||
|
order: "asc",
|
||||||
|
page: currentPage,
|
||||||
|
per_page: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('API Response:', {
|
||||||
|
page: currentPage,
|
||||||
|
totalPages: response.meta.total_pages,
|
||||||
|
callsCount: response.calls?.length,
|
||||||
|
params: {
|
||||||
|
from: Math.floor(start.getTime() / 1000),
|
||||||
|
to: Math.floor(end.getTime() / 1000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.calls) {
|
||||||
|
throw new Error("Invalid API response format");
|
||||||
|
}
|
||||||
|
|
||||||
|
allCalls = [...allCalls, ...response.calls];
|
||||||
|
hasMore = response.meta.next_page_link !== null;
|
||||||
|
totalPages = response.meta.total_pages;
|
||||||
|
currentPage++;
|
||||||
|
|
||||||
|
if (hasMore) {
|
||||||
|
// Rate limiting pause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCalls;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching all calls:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async makeRequest(endpoint, params = {}) {
|
||||||
|
try {
|
||||||
|
console.log('Making API request:', {
|
||||||
|
endpoint,
|
||||||
|
params
|
||||||
|
});
|
||||||
|
const response = await axios.get(`${this.baseUrl}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${this.auth}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
console.log("Rate limit reached, waiting before retry...");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
return this.makeRequest(endpoint, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleApiError(error, `Error making request to ${endpoint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateApiResponse(response, context = "") {
|
||||||
|
if (!response || typeof response !== "object") {
|
||||||
|
throw new Error(`${context}: Invalid API response format`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(`${context}: ${response.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPaginationInfo(meta) {
|
||||||
|
return {
|
||||||
|
currentPage: meta.current_page,
|
||||||
|
totalPages: meta.total_pages,
|
||||||
|
hasNextPage: meta.next_page_link !== null,
|
||||||
|
totalRecords: meta.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
|
|||||||
|
import { createTimeManager } from '../../utils/timeUtils.js';
|
||||||
|
|
||||||
|
export class BaseService {
|
||||||
|
constructor(config) {
|
||||||
|
this.config = config;
|
||||||
|
this.mongodb = config.mongodb;
|
||||||
|
this.redis = config.redis;
|
||||||
|
this.logger = config.logger;
|
||||||
|
this.timeManager = createTimeManager(config.timezone, config.dayStartsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleApiError(error, context = '') {
|
||||||
|
this.logger.error(`API Error ${context}:`, {
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.response) {
|
||||||
|
const status = error.response.status;
|
||||||
|
const message = error.response.data?.message || error.response.statusText;
|
||||||
|
|
||||||
|
if (status === 429) {
|
||||||
|
throw new Error('API rate limit exceeded. Please try again later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`API error (${status}): ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
export class DataManager {
|
||||||
|
constructor(mongodb, redis, timeManager, options) {
|
||||||
|
this.mongodb = mongodb;
|
||||||
|
this.redis = redis;
|
||||||
|
this.timeManager = timeManager;
|
||||||
|
this.options = options || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDate(d) {
|
||||||
|
if (d instanceof Date) return d;
|
||||||
|
if (typeof d === 'string') return new Date(d);
|
||||||
|
if (typeof d === 'number') return new Date(d);
|
||||||
|
if (d && d.date) return new Date(d.date); // Handle MongoDB records
|
||||||
|
console.error('Invalid date value:', d);
|
||||||
|
return new Date(); // fallback to current date
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(dateRange, fetchFn) {
|
||||||
|
try {
|
||||||
|
// Get historical data from MongoDB
|
||||||
|
const historicalData = await this.getHistoricalDays(dateRange.start, dateRange.end);
|
||||||
|
|
||||||
|
// Find any missing date ranges
|
||||||
|
const missingRanges = this.findMissingDateRanges(dateRange.start, dateRange.end, historicalData);
|
||||||
|
|
||||||
|
// Fetch missing data
|
||||||
|
for (const range of missingRanges) {
|
||||||
|
const data = await fetchFn(range);
|
||||||
|
await this.storeHistoricalPeriod(range.start, range.end, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated historical data
|
||||||
|
const updatedData = await this.getHistoricalDays(dateRange.start, dateRange.end);
|
||||||
|
|
||||||
|
// Handle both nested and flat data structures
|
||||||
|
if (updatedData && updatedData.length > 0) {
|
||||||
|
// Process each record and combine them
|
||||||
|
const processedData = updatedData.map(record => {
|
||||||
|
if (record.data) {
|
||||||
|
return record.data;
|
||||||
|
}
|
||||||
|
if (record.total !== undefined) {
|
||||||
|
return {
|
||||||
|
total: record.total,
|
||||||
|
by_direction: record.by_direction,
|
||||||
|
by_status: record.by_status,
|
||||||
|
by_missed_reason: record.by_missed_reason,
|
||||||
|
by_hour: record.by_hour,
|
||||||
|
by_users: record.by_users,
|
||||||
|
daily_data: record.daily_data,
|
||||||
|
duration_distribution: record.duration_distribution,
|
||||||
|
average_duration: record.average_duration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
// Combine the data
|
||||||
|
if (processedData.length > 0) {
|
||||||
|
return this.combineMetrics(processedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise process as raw call data
|
||||||
|
return this.processCallData(updatedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getData:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findMissingDateRanges(start, end, existingDates) {
|
||||||
|
const missingRanges = [];
|
||||||
|
const existingDatesSet = new Set(
|
||||||
|
existingDates.map((d) => {
|
||||||
|
// Handle both nested and flat data structures
|
||||||
|
const date = d.date ? d.date : d;
|
||||||
|
return this.ensureDate(date).toISOString().split("T")[0];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let current = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
|
||||||
|
while (current < endDate) {
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(current);
|
||||||
|
const dayKey = dayBounds.start.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
if (!existingDatesSet.has(dayKey)) {
|
||||||
|
// Found a missing day
|
||||||
|
const missingStart = new Date(dayBounds.start);
|
||||||
|
const missingEnd = new Date(dayBounds.end);
|
||||||
|
|
||||||
|
missingRanges.push({
|
||||||
|
start: missingStart,
|
||||||
|
end: missingEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next day using timeManager to ensure proper business day boundaries
|
||||||
|
current = new Date(dayBounds.end.getTime() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return missingRanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentDay(fetchFn) {
|
||||||
|
const now = new Date();
|
||||||
|
const todayBounds = this.timeManager.getDayBounds(now);
|
||||||
|
const todayKey = this.timeManager.formatDate(todayBounds.start);
|
||||||
|
const cacheKey = `${this.options.collection}:current_day:${todayKey}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
if (this.redis?.isOpen) {
|
||||||
|
const cached = await this.redis.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const parsedCache = JSON.parse(cached);
|
||||||
|
if (parsedCache.total !== undefined) {
|
||||||
|
// Use timeManager to check if the cached data is for today
|
||||||
|
const cachedDate = new Date(parsedCache.daily_data[0].date);
|
||||||
|
const isToday = this.timeManager.isToday(cachedDate);
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return parsedCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get safe end time that's never in the future
|
||||||
|
const safeEnd = this.timeManager.getCurrentBusinessDayEnd();
|
||||||
|
|
||||||
|
// Fetch and process current day data with safe end time
|
||||||
|
const data = await fetchFn({
|
||||||
|
start: todayBounds.start,
|
||||||
|
end: safeEnd
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the data with a shorter TTL for today's data
|
||||||
|
if (this.redis?.isOpen) {
|
||||||
|
const ttl = Math.min(
|
||||||
|
this.options.redisTTL,
|
||||||
|
60 * 5 // 5 minutes max for today's data
|
||||||
|
);
|
||||||
|
await this.redis.set(cacheKey, JSON.stringify(data), {
|
||||||
|
EX: ttl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getCurrentDay:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDayCount(start, end) {
|
||||||
|
// Calculate full days between dates using timeManager
|
||||||
|
const startDay = this.timeManager.getDayBounds(start);
|
||||||
|
const endDay = this.timeManager.getDayBounds(end);
|
||||||
|
return Math.ceil((endDay.end - startDay.start) / (24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMissingDays(start, end, existingData, fetchFn) {
|
||||||
|
const existingDates = new Set(
|
||||||
|
existingData.map((d) => this.timeManager.formatDate(d.date))
|
||||||
|
);
|
||||||
|
const missingData = [];
|
||||||
|
|
||||||
|
let currentDate = new Date(start);
|
||||||
|
while (currentDate < end) {
|
||||||
|
const dayBounds = this.timeManager.getDayBounds(currentDate);
|
||||||
|
const dateString = this.timeManager.formatDate(dayBounds.start);
|
||||||
|
|
||||||
|
if (!existingDates.has(dateString)) {
|
||||||
|
const data = await fetchFn({
|
||||||
|
start: dayBounds.start,
|
||||||
|
end: dayBounds.end,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.storeHistoricalDay(dayBounds.start, data);
|
||||||
|
missingData.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next day using timeManager to ensure proper business day boundaries
|
||||||
|
currentDate = new Date(dayBounds.end.getTime() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return missingData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistoricalDays(start, end) {
|
||||||
|
try {
|
||||||
|
if (!this.mongodb) return [];
|
||||||
|
|
||||||
|
const collection = this.mongodb.collection(this.options.collection);
|
||||||
|
const startDay = this.timeManager.getDayBounds(start);
|
||||||
|
const endDay = this.timeManager.getDayBounds(end);
|
||||||
|
|
||||||
|
const records = await collection
|
||||||
|
.find({
|
||||||
|
date: {
|
||||||
|
$gte: startDay.start,
|
||||||
|
$lt: endDay.start,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.sort({ date: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return records;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting historical days:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combineMetrics(metricsArray) {
|
||||||
|
if (!metricsArray || metricsArray.length === 0) return null;
|
||||||
|
if (metricsArray.length === 1) return metricsArray[0];
|
||||||
|
|
||||||
|
const combined = {
|
||||||
|
total: 0,
|
||||||
|
by_direction: { inbound: 0, outbound: 0 },
|
||||||
|
by_status: { answered: 0, missed: 0 },
|
||||||
|
by_missed_reason: {},
|
||||||
|
by_hour: Array(24).fill(0),
|
||||||
|
by_users: {},
|
||||||
|
daily_data: [],
|
||||||
|
duration_distribution: [
|
||||||
|
{ range: '0-1m', count: 0 },
|
||||||
|
{ range: '1-5m', count: 0 },
|
||||||
|
{ range: '5-15m', count: 0 },
|
||||||
|
{ range: '15-30m', count: 0 },
|
||||||
|
{ range: '30m+', count: 0 }
|
||||||
|
],
|
||||||
|
average_duration: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalAnswered = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
|
||||||
|
metricsArray.forEach(metrics => {
|
||||||
|
// Sum basic metrics
|
||||||
|
combined.total += metrics.total;
|
||||||
|
combined.by_direction.inbound += metrics.by_direction.inbound;
|
||||||
|
combined.by_direction.outbound += metrics.by_direction.outbound;
|
||||||
|
combined.by_status.answered += metrics.by_status.answered;
|
||||||
|
combined.by_status.missed += metrics.by_status.missed;
|
||||||
|
|
||||||
|
// Combine missed reasons
|
||||||
|
Object.entries(metrics.by_missed_reason).forEach(([reason, count]) => {
|
||||||
|
combined.by_missed_reason[reason] = (combined.by_missed_reason[reason] || 0) + count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sum hourly data
|
||||||
|
metrics.by_hour.forEach((count, hour) => {
|
||||||
|
combined.by_hour[hour] += count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine user data
|
||||||
|
Object.entries(metrics.by_users).forEach(([userId, userData]) => {
|
||||||
|
if (!combined.by_users[userId]) {
|
||||||
|
combined.by_users[userId] = {
|
||||||
|
id: userData.id,
|
||||||
|
name: userData.name,
|
||||||
|
total: 0,
|
||||||
|
answered: 0,
|
||||||
|
missed: 0,
|
||||||
|
total_duration: 0,
|
||||||
|
average_duration: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
combined.by_users[userId].total += userData.total;
|
||||||
|
combined.by_users[userId].answered += userData.answered;
|
||||||
|
combined.by_users[userId].missed += userData.missed;
|
||||||
|
combined.by_users[userId].total_duration += userData.total_duration || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine duration distribution
|
||||||
|
metrics.duration_distribution.forEach((dist, index) => {
|
||||||
|
combined.duration_distribution[index].count += dist.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accumulate for average duration calculation
|
||||||
|
if (metrics.average_duration && metrics.by_status.answered) {
|
||||||
|
totalDuration += metrics.average_duration * metrics.by_status.answered;
|
||||||
|
totalAnswered += metrics.by_status.answered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge daily data
|
||||||
|
if (metrics.daily_data) {
|
||||||
|
combined.daily_data.push(...metrics.daily_data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate final average duration
|
||||||
|
if (totalAnswered > 0) {
|
||||||
|
combined.average_duration = Math.round(totalDuration / totalAnswered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate user averages
|
||||||
|
Object.values(combined.by_users).forEach(user => {
|
||||||
|
if (user.answered > 0) {
|
||||||
|
user.average_duration = Math.round(user.total_duration / user.answered);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort and deduplicate daily data
|
||||||
|
combined.daily_data = Array.from(
|
||||||
|
new Map(combined.daily_data.map(item => [item.date, item])).values()
|
||||||
|
).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/dashboard';
|
||||||
|
const DB_NAME = process.env.MONGODB_DB || 'dashboard';
|
||||||
|
|
||||||
|
export async function connectMongoDB() {
|
||||||
|
try {
|
||||||
|
const client = await MongoClient.connect(MONGODB_URI);
|
||||||
|
console.log('Connected to MongoDB');
|
||||||
|
return client.db(DB_NAME);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MongoDB connection error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function createLogger(service) {
|
||||||
|
return winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.json()
|
||||||
|
),
|
||||||
|
defaultMeta: { service },
|
||||||
|
transports: [
|
||||||
|
// Write all logs to console
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.simple()
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
// Write all logs to service-specific files
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join('logs', `${service}-error.log`),
|
||||||
|
level: 'error'
|
||||||
|
}),
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join('logs', `${service}-combined.log`)
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
|
||||||
|
export async function createRedisClient() {
|
||||||
|
try {
|
||||||
|
const client = createClient({
|
||||||
|
url: REDIS_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
console.log('Connected to Redis');
|
||||||
|
|
||||||
|
client.on('error', (err) => {
|
||||||
|
console.error('Redis error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Redis connection error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
class TimeManager {
|
||||||
|
static ALLOWED_RANGES = ['today', 'yesterday', 'last2days', 'last7days', 'last30days', 'last90days',
|
||||||
|
'previous7days', 'previous30days', 'previous90days'];
|
||||||
|
|
||||||
|
constructor(timezone = 'America/New_York', dayStartsAt = 1) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
this.dayStartsAt = dayStartsAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDayBounds(date) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const targetDate = new Date(date);
|
||||||
|
|
||||||
|
// For today
|
||||||
|
if (
|
||||||
|
targetDate.getUTCFullYear() === now.getUTCFullYear() &&
|
||||||
|
targetDate.getUTCMonth() === now.getUTCMonth() &&
|
||||||
|
targetDate.getUTCDate() === now.getUTCDate()
|
||||||
|
) {
|
||||||
|
// If current time is before day start (1 AM ET / 6 AM UTC),
|
||||||
|
// use previous day's start until now
|
||||||
|
const todayStart = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate(),
|
||||||
|
this.dayStartsAt + 5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
));
|
||||||
|
|
||||||
|
if (now < todayStart) {
|
||||||
|
const yesterdayStart = new Date(todayStart);
|
||||||
|
yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1);
|
||||||
|
return { start: yesterdayStart, end: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: todayStart, end: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For past days, use full 24-hour period
|
||||||
|
const normalizedDate = new Date(Date.UTC(
|
||||||
|
targetDate.getUTCFullYear(),
|
||||||
|
targetDate.getUTCMonth(),
|
||||||
|
targetDate.getUTCDate()
|
||||||
|
));
|
||||||
|
|
||||||
|
const dayStart = new Date(normalizedDate);
|
||||||
|
dayStart.setUTCHours(this.dayStartsAt + 5, 0, 0, 0);
|
||||||
|
|
||||||
|
const dayEnd = new Date(dayStart);
|
||||||
|
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
|
||||||
|
|
||||||
|
return { start: dayStart, end: dayEnd };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getDayBounds:', error);
|
||||||
|
throw new Error(`Failed to calculate day bounds: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDateRange(period) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const todayBounds = this.getDayBounds(now);
|
||||||
|
const end = new Date();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
return {
|
||||||
|
start: todayBounds.start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
case 'yesterday': {
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
return this.getDayBounds(yesterday);
|
||||||
|
}
|
||||||
|
case 'last2days': {
|
||||||
|
const twoDaysAgo = new Date(now);
|
||||||
|
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
||||||
|
return this.getDayBounds(twoDaysAgo);
|
||||||
|
}
|
||||||
|
case 'last7days': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 6);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous7days': {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 7);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 6);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last30days': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 29);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous30days': {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 30);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 29);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 89);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous90days': {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 90);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 89);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported time period: ${period}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getDateRange:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviousPeriod(period) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'today':
|
||||||
|
return 'yesterday';
|
||||||
|
case 'yesterday': {
|
||||||
|
// Return bounds for 2 days ago
|
||||||
|
const twoDaysAgo = new Date(now);
|
||||||
|
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
||||||
|
return this.getDayBounds(twoDaysAgo);
|
||||||
|
}
|
||||||
|
case 'last7days': {
|
||||||
|
// Return bounds for previous 7 days
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 7);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 7);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last30days': {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 30);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 30);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setDate(end.getDate() - 90);
|
||||||
|
const start = new Date(end);
|
||||||
|
start.setDate(start.getDate() - 90);
|
||||||
|
return {
|
||||||
|
start: this.getDayBounds(start).start,
|
||||||
|
end: this.getDayBounds(end).end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported time period: ${period}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getPreviousPeriod:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentBusinessDayEnd() {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const todayBounds = this.getDayBounds(now);
|
||||||
|
|
||||||
|
// If current time is before day start (1 AM ET / 6 AM UTC),
|
||||||
|
// then we're still in yesterday's business day
|
||||||
|
const todayStart = new Date(Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate(),
|
||||||
|
this.dayStartsAt + 5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
));
|
||||||
|
|
||||||
|
if (now < todayStart) {
|
||||||
|
const yesterdayBounds = this.getDayBounds(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
||||||
|
return yesterdayBounds.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the earlier of current time or today's end
|
||||||
|
return now < todayBounds.end ? now : todayBounds.end;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getCurrentBusinessDayEnd:', error);
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isValidTimeRange(timeRange) {
|
||||||
|
return TimeManager.ALLOWED_RANGES.includes(timeRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
isToday(date) {
|
||||||
|
const now = new Date();
|
||||||
|
const targetDate = new Date(date);
|
||||||
|
return (
|
||||||
|
targetDate.getUTCFullYear() === now.getUTCFullYear() &&
|
||||||
|
targetDate.getUTCMonth() === now.getUTCMonth() &&
|
||||||
|
targetDate.getUTCDate() === now.getUTCDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date) {
|
||||||
|
try {
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
timeZone: this.timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTimeManager = (timezone, dayStartsAt) => new TimeManager(timezone, dayStartsAt);
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3003
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=your-secret-key-here
|
||||||
|
DASHBOARD_PASSWORD=your-dashboard-password-here
|
||||||
|
|
||||||
|
# Cookie Settings
|
||||||
|
COOKIE_DOMAIN=localhost # In production: .kent.pw
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// auth-server/index.js
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
// Debug environment variables
|
||||||
|
console.log('Environment variables loaded from:', path.join(__dirname, '.env'));
|
||||||
|
console.log('Current directory:', __dirname);
|
||||||
|
console.log('Available env vars:', Object.keys(process.env));
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3003;
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD;
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
if (!JWT_SECRET || !DASHBOARD_PASSWORD) {
|
||||||
|
console.error('Missing required environment variables:');
|
||||||
|
if (!JWT_SECRET) console.error('- JWT_SECRET');
|
||||||
|
if (!DASHBOARD_PASSWORD) console.error('- DASHBOARD_PASSWORD');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Configure CORS
|
||||||
|
const corsOptions = {
|
||||||
|
origin: function(origin, callback) {
|
||||||
|
const allowedOrigins = [
|
||||||
|
'http://localhost:3000',
|
||||||
|
'https://dashboard.kent.pw'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('CORS check for origin:', origin);
|
||||||
|
|
||||||
|
// Check if origin is allowed
|
||||||
|
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error('Not allowed by CORS'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'Accept'],
|
||||||
|
exposedHeaders: ['Set-Cookie']
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
app.options('*', cors(corsOptions));
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
|
||||||
|
console.log('Headers:', req.headers);
|
||||||
|
console.log('Cookies:', req.cookies);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
app.post('/login', (req, res) => {
|
||||||
|
console.log('Login attempt received');
|
||||||
|
console.log('Request body:', req.body);
|
||||||
|
console.log('Origin:', req.headers.origin);
|
||||||
|
|
||||||
|
const { password } = req.body;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
console.log('No password provided');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Password is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Comparing passwords...');
|
||||||
|
console.log('Provided password length:', password.length);
|
||||||
|
console.log('Expected password length:', DASHBOARD_PASSWORD.length);
|
||||||
|
|
||||||
|
if (password === DASHBOARD_PASSWORD) {
|
||||||
|
console.log('Password matched');
|
||||||
|
const token = jwt.sign({ authorized: true }, JWT_SECRET, {
|
||||||
|
expiresIn: '24h'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine if request is from localhost
|
||||||
|
const isLocalhost = req.headers.origin?.includes('localhost');
|
||||||
|
|
||||||
|
const cookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !isLocalhost,
|
||||||
|
sameSite: isLocalhost ? 'lax' : 'none',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add domain only for production
|
||||||
|
if (!isLocalhost) {
|
||||||
|
cookieOptions.domain = '.kent.pw';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Setting cookie with options:', cookieOptions);
|
||||||
|
res.cookie('token', token, cookieOptions);
|
||||||
|
|
||||||
|
console.log('Response headers:', res.getHeaders());
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
debug: {
|
||||||
|
origin: req.headers.origin,
|
||||||
|
cookieOptions
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Password mismatch');
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid password'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify the check endpoint to log more info
|
||||||
|
app.get('/check', (req, res) => {
|
||||||
|
console.log('Auth check received');
|
||||||
|
console.log('All cookies:', req.cookies);
|
||||||
|
console.log('Headers:', req.headers);
|
||||||
|
|
||||||
|
const token = req.cookies.token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log('No token found in cookies');
|
||||||
|
return res.status(401).json({
|
||||||
|
authenticated: false,
|
||||||
|
error: 'no_token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
console.log('Token verified successfully:', decoded);
|
||||||
|
res.json({ authenticated: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.log('Token verification failed:', err.message);
|
||||||
|
res.status(401).json({
|
||||||
|
authenticated: false,
|
||||||
|
error: 'invalid_token',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/logout', (req, res) => {
|
||||||
|
const isLocalhost = req.headers.origin?.includes('localhost');
|
||||||
|
const cookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !isLocalhost,
|
||||||
|
sameSite: isLocalhost ? 'lax' : 'none',
|
||||||
|
path: '/',
|
||||||
|
domain: isLocalhost ? undefined : '.kent.pw'
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Clearing cookie with options:', cookieOptions);
|
||||||
|
res.clearCookie('token', cookieOptions);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Server error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
error: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Auth server running on port ${PORT}`);
|
||||||
|
console.log('Environment:', process.env.NODE_ENV);
|
||||||
|
console.log('CORS origins:', corsOptions.origin);
|
||||||
|
console.log('JWT_SECRET length:', JWT_SECRET?.length);
|
||||||
|
console.log('DASHBOARD_PASSWORD length:', DASHBOARD_PASSWORD?.length);
|
||||||
|
});
|
||||||
+1044
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "auth-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// ecosystem.config.js
|
||||||
|
const path = require('path');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
// Load environment variables safely with error handling
|
||||||
|
const loadEnvFile = (envPath) => {
|
||||||
|
try {
|
||||||
|
console.log('Loading env from:', envPath);
|
||||||
|
const result = dotenv.config({ path: envPath });
|
||||||
|
if (result.error) {
|
||||||
|
console.warn(`Warning: .env file not found or invalid at ${envPath}:`, result.error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
console.log('Env variables loaded from', envPath, ':', Object.keys(result.parsed || {}));
|
||||||
|
return result.parsed || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Warning: Error loading .env file at ${envPath}:`, error.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load environment variables for each server
|
||||||
|
const authEnv = loadEnvFile(path.resolve(__dirname, 'auth-server/.env'));
|
||||||
|
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'aircall-server/.env'));
|
||||||
|
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'klaviyo-server/.env'));
|
||||||
|
|
||||||
|
// Common log settings for all apps
|
||||||
|
const logSettings = {
|
||||||
|
log_rotate: true,
|
||||||
|
max_size: '10M',
|
||||||
|
retain: '10',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common app settings
|
||||||
|
const commonSettings = {
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
time: true,
|
||||||
|
...logSettings,
|
||||||
|
ignore_watch: [
|
||||||
|
'node_modules',
|
||||||
|
'logs',
|
||||||
|
'.git',
|
||||||
|
'*.log'
|
||||||
|
],
|
||||||
|
min_uptime: 5000,
|
||||||
|
max_restarts: 5,
|
||||||
|
restart_delay: 4000,
|
||||||
|
listen_timeout: 50000,
|
||||||
|
kill_timeout: 5000,
|
||||||
|
node_args: '--max-old-space-size=1536'
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
...commonSettings,
|
||||||
|
name: 'auth-server',
|
||||||
|
script: './auth-server/index.js',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3003,
|
||||||
|
...authEnv
|
||||||
|
},
|
||||||
|
error_file: 'auth-server/logs/pm2/err.log',
|
||||||
|
out_file: 'auth-server/logs/pm2/out.log',
|
||||||
|
log_file: 'auth-server/logs/pm2/combined.log',
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3003
|
||||||
|
},
|
||||||
|
env_development: {
|
||||||
|
NODE_ENV: 'development',
|
||||||
|
PORT: 3003
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...commonSettings,
|
||||||
|
name: 'aircall-server',
|
||||||
|
script: './aircall-server/server.js',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
AIRCALL_PORT: 3002,
|
||||||
|
...aircallEnv
|
||||||
|
},
|
||||||
|
error_file: 'aircall-server/logs/pm2/err.log',
|
||||||
|
out_file: 'aircall-server/logs/pm2/out.log',
|
||||||
|
log_file: 'aircall-server/logs/pm2/combined.log',
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
AIRCALL_PORT: 3002
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...commonSettings,
|
||||||
|
name: 'klaviyo-server',
|
||||||
|
script: './klaviyo-server/server.js',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
KLAVIYO_PORT: 3004,
|
||||||
|
...klaviyoEnv
|
||||||
|
},
|
||||||
|
error_file: 'klaviyo-server/logs/pm2/err.log',
|
||||||
|
out_file: 'klaviyo-server/logs/pm2/out.log',
|
||||||
|
log_file: 'klaviyo-server/logs/pm2/combined.log',
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
KLAVIYO_PORT: 3004
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 363 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/dashboard.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-ClSvHF_l.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zehgRTIA.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-T9y3LwG-.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
+1950
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "klaviyo-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Klaviyo API integration server",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"esm": "^3.2.25",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,429 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { EventsService } from '../services/events.service.js';
|
||||||
|
import { TimeManager } from '../utils/time.utils.js';
|
||||||
|
import { RedisService } from '../services/redis.service.js';
|
||||||
|
|
||||||
|
// Import METRIC_IDS from events service
|
||||||
|
const METRIC_IDS = {
|
||||||
|
PLACED_ORDER: 'Y8cqcF',
|
||||||
|
SHIPPED_ORDER: 'VExpdL',
|
||||||
|
ACCOUNT_CREATED: 'TeeypV',
|
||||||
|
CANCELED_ORDER: 'YjVMNg',
|
||||||
|
NEW_BLOG_POST: 'YcxeDr',
|
||||||
|
PAYMENT_REFUNDED: 'R7XUYh'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEventsRouter(apiKey, apiRevision) {
|
||||||
|
const router = express.Router();
|
||||||
|
const timeManager = new TimeManager();
|
||||||
|
const eventsService = new EventsService(apiKey, apiRevision);
|
||||||
|
const redisService = new RedisService();
|
||||||
|
|
||||||
|
// Get events with optional filtering
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pageSize: parseInt(req.query.pageSize) || 50,
|
||||||
|
sort: req.query.sort || '-datetime',
|
||||||
|
metricId: req.query.metricId,
|
||||||
|
startDate: req.query.startDate,
|
||||||
|
endDate: req.query.endDate,
|
||||||
|
pageCursor: req.query.pageCursor,
|
||||||
|
fields: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse fields parameter if provided
|
||||||
|
if (req.query.fields) {
|
||||||
|
try {
|
||||||
|
params.fields = JSON.parse(req.query.fields);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Events Route] Invalid fields parameter:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Events Route] Fetching events with params:', params);
|
||||||
|
const data = await eventsService.getEvents(params);
|
||||||
|
console.log('[Events Route] Success:', {
|
||||||
|
count: data.data?.length || 0,
|
||||||
|
included: data.included?.length || 0
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Events Route] Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get events by time range
|
||||||
|
router.get('/by-time/:timeRange', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange } = req.params;
|
||||||
|
const { metricId, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (timeRange === 'custom') {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid date range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await eventsService.getEvents({
|
||||||
|
metricId,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await eventsService.getEventsByTimeRange(
|
||||||
|
timeRange,
|
||||||
|
{ metricId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get comprehensive statistics for a time period
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
console.log('[Events Route] Stats request:', {
|
||||||
|
timeRange,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Events Route] Calculating period stats with params:', params);
|
||||||
|
const stats = await eventsService.calculatePeriodStats(params);
|
||||||
|
console.log('[Events Route] Stats response:', {
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO()
|
||||||
|
},
|
||||||
|
shippedCount: stats?.shipping?.shippedCount,
|
||||||
|
totalOrders: stats?.orderCount
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new route for detailed stats
|
||||||
|
router.get('/stats/details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metric, daily = false } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metric,
|
||||||
|
daily: daily === 'true' || daily === true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = redisService._getCacheKey('stats:details', params);
|
||||||
|
const cachedData = await redisService.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[Events Route] Cache hit for detailed stats');
|
||||||
|
return res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats: cachedData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await eventsService.calculateDetailedStats(params);
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const ttl = redisService._getTTL(timeRange);
|
||||||
|
await redisService.set(cacheKey, stats, ttl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get product statistics for a time period
|
||||||
|
router.get('/products', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = redisService._getCacheKey('events', params);
|
||||||
|
const cachedData = await redisService.getEventData('products', params);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[Events Route] Cache hit for products');
|
||||||
|
return res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
products: cachedData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await eventsService.calculatePeriodStats(params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get event feed (multiple event types sorted by time)
|
||||||
|
router.get('/feed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metricIds } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metricIds: metricIds ? JSON.parse(metricIds) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await eventsService.getMultiMetricEvents(params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get aggregated events data
|
||||||
|
router.get('/aggregate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, interval = 'day', metricId, property } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metricId,
|
||||||
|
interval,
|
||||||
|
property
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await eventsService.getEvents(params);
|
||||||
|
const groupedData = timeManager.groupEventsByInterval(result.data, interval, property);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
data: groupedData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get date range for a given time period
|
||||||
|
router.get("/dateRange", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else {
|
||||||
|
range = timeManager.getDateRange(timeRange || 'today');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid time range parameters"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting date range:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to get date range"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear cache for a specific time range
|
||||||
|
router.post("/clearCache", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.body;
|
||||||
|
await redisService.clearCache({ timeRange, startDate, endDate });
|
||||||
|
res.json({ message: "Cache cleared successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
res.status(500).json({ error: "Failed to clear cache" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new batch metrics endpoint
|
||||||
|
router.get('/batch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metrics } = req.query;
|
||||||
|
|
||||||
|
// Parse metrics array from query
|
||||||
|
const metricsList = metrics ? JSON.parse(metrics) : [];
|
||||||
|
|
||||||
|
const params = timeRange === 'custom'
|
||||||
|
? { startDate, endDate, metrics: metricsList }
|
||||||
|
: { timeRange, metrics: metricsList };
|
||||||
|
|
||||||
|
const results = await eventsService.getBatchMetrics(params);
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Events Route] Error in batch request:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { createMetricsRoutes } from './metrics.routes.js';
|
||||||
|
import { createEventsRouter } from './events.routes.js';
|
||||||
|
|
||||||
|
export function createApiRouter(apiKey, apiRevision) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Mount metrics routes
|
||||||
|
router.use('/metrics', createMetricsRoutes(apiKey, apiRevision));
|
||||||
|
|
||||||
|
// Mount events routes
|
||||||
|
router.use('/events', createEventsRouter(apiKey, apiRevision));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { MetricsService } from '../services/metrics.service.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
export function createMetricsRoutes(apiKey, apiRevision) {
|
||||||
|
const metricsService = new MetricsService(apiKey, apiRevision);
|
||||||
|
|
||||||
|
// Get all metrics
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[Metrics Route] Fetching metrics');
|
||||||
|
const data = await metricsService.getMetrics();
|
||||||
|
console.log('[Metrics Route] Success:', {
|
||||||
|
count: data.data?.length || 0
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Metrics Route] Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { createApiRouter } from './routes/index.js';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// Get directory name in ES modules
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
const envPath = path.resolve(__dirname, '.env');
|
||||||
|
console.log('[Server] Loading .env file from:', envPath);
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
|
// Debug environment variables (without exposing sensitive data)
|
||||||
|
console.log('[Server] Environment variables loaded:', {
|
||||||
|
REDIS_HOST: process.env.REDIS_HOST || '(not set)',
|
||||||
|
REDIS_PORT: process.env.REDIS_PORT || '(not set)',
|
||||||
|
REDIS_USERNAME: process.env.REDIS_USERNAME || '(not set)',
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD ? '(set)' : '(not set)',
|
||||||
|
NODE_ENV: process.env.NODE_ENV || '(not set)',
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = process.env.KLAVIYO_PORT || 3004;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Debug middleware to log all requests
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and mount API routes
|
||||||
|
const apiRouter = createApiRouter(
|
||||||
|
process.env.KLAVIYO_API_KEY,
|
||||||
|
process.env.KLAVIYO_API_REVISION || '2024-02-15'
|
||||||
|
);
|
||||||
|
app.use('/api/klaviyo', apiRouter);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Internal server error',
|
||||||
|
details: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`Klaviyo server listening at http://0.0.0.0:${port}`);
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,214 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { TimeManager } from '../utils/time.utils.js';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// Get directory name in ES modules
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables again (redundant but safe)
|
||||||
|
const envPath = path.resolve(__dirname, '../.env');
|
||||||
|
console.log('[RedisService] Loading .env file from:', envPath);
|
||||||
|
dotenv.config({ path: envPath });
|
||||||
|
|
||||||
|
export class RedisService {
|
||||||
|
constructor() {
|
||||||
|
this.timeManager = new TimeManager();
|
||||||
|
this.DEFAULT_TTL = 5 * 60; // 5 minutes default TTL
|
||||||
|
this.isConnected = false;
|
||||||
|
this._initializeRedis();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initializeRedis() {
|
||||||
|
try {
|
||||||
|
// Debug: Print all environment variables we're looking for
|
||||||
|
console.log('[RedisService] Environment variables state:', {
|
||||||
|
REDIS_HOST: process.env.REDIS_HOST ? '(set)' : '(not set)',
|
||||||
|
REDIS_PORT: process.env.REDIS_PORT ? '(set)' : '(not set)',
|
||||||
|
REDIS_USERNAME: process.env.REDIS_USERNAME ? '(set)' : '(not set)',
|
||||||
|
REDIS_PASSWORD: process.env.REDIS_PASSWORD ? '(set)' : '(not set)',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log Redis configuration (without password)
|
||||||
|
const host = process.env.REDIS_HOST || 'localhost';
|
||||||
|
const port = parseInt(process.env.REDIS_PORT) || 6379;
|
||||||
|
const username = process.env.REDIS_USERNAME || 'default';
|
||||||
|
const password = process.env.REDIS_PASSWORD;
|
||||||
|
|
||||||
|
console.log('[RedisService] Initializing Redis with config:', {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
hasPassword: !!password
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
retryStrategy: (times) => {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
enableReadyCheck: true,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
showFriendlyErrorStack: true,
|
||||||
|
retryUnfulfilled: true,
|
||||||
|
maxRetryAttempts: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only add password if it exists
|
||||||
|
if (password) {
|
||||||
|
console.log('[RedisService] Adding password to config');
|
||||||
|
config.password = password;
|
||||||
|
} else {
|
||||||
|
console.warn('[RedisService] No Redis password found in environment variables!');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new Redis(config);
|
||||||
|
|
||||||
|
// Handle connection events
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
console.log('[RedisService] Connected to Redis');
|
||||||
|
this.isConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('ready', () => {
|
||||||
|
console.log('[RedisService] Redis is ready');
|
||||||
|
this.isConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (err) => {
|
||||||
|
console.error('[RedisService] Redis error:', err);
|
||||||
|
this.isConnected = false;
|
||||||
|
// Log more details about the error
|
||||||
|
if (err.code === 'WRONGPASS') {
|
||||||
|
console.error('[RedisService] Authentication failed. Please check your Redis password.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
console.log('[RedisService] Redis connection closed');
|
||||||
|
this.isConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('reconnecting', (params) => {
|
||||||
|
console.log('[RedisService] Reconnecting to Redis:', params);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error initializing Redis:', error);
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to generate cache keys
|
||||||
|
_getCacheKey(type, params = {}) {
|
||||||
|
const { timeRange, startDate, endDate, metricId, metric, daily } = params;
|
||||||
|
let key = `klaviyo:${type}`;
|
||||||
|
|
||||||
|
if (type === 'stats:details') {
|
||||||
|
key += `:${metric}${daily ? ':daily' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRange) {
|
||||||
|
key += `:${timeRange}${metricId ? `:${metricId}` : ''}`;
|
||||||
|
} else if (startDate && endDate) {
|
||||||
|
key += `:custom:${startDate}:${endDate}${metricId ? `:${metricId}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TTL based on time range
|
||||||
|
_getTTL(timeRange) {
|
||||||
|
const TTL_MAP = {
|
||||||
|
'today': 2 * 60, // 2 minutes
|
||||||
|
'yesterday': 30 * 60, // 30 minutes
|
||||||
|
'thisWeek': 5 * 60, // 5 minutes
|
||||||
|
'lastWeek': 60 * 60, // 1 hour
|
||||||
|
'thisMonth': 10 * 60, // 10 minutes
|
||||||
|
'lastMonth': 2 * 60 * 60, // 2 hours
|
||||||
|
'last7days': 5 * 60, // 5 minutes
|
||||||
|
'last30days': 15 * 60, // 15 minutes
|
||||||
|
'custom': 15 * 60 // 15 minutes
|
||||||
|
};
|
||||||
|
return TTL_MAP[timeRange] || this.DEFAULT_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventData(type, params) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseKey = this._getCacheKey('events', params);
|
||||||
|
const data = await this.get(`${baseKey}:${type}`);
|
||||||
|
return data;
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
// Cache raw event data
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user