Overdue initial commit
This commit is contained in:
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -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
|
||||
19
Dev.code-workspace
Normal file
19
Dev.code-workspace
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"folder-color.pathColors": [
|
||||
{
|
||||
"folderPath": "Dev/dashboard/",
|
||||
"badge": "🔵"
|
||||
},
|
||||
{
|
||||
"folderPath": "Dev/dashboard-server/",
|
||||
"badge": "🟣"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
dashboard-server/._.env-future
Normal file
BIN
dashboard-server/._.env-future
Normal file
Binary file not shown.
BIN
dashboard-server/._aircall-server
Normal file
BIN
dashboard-server/._aircall-server
Normal file
Binary file not shown.
BIN
dashboard-server/._auth-server
Normal file
BIN
dashboard-server/._auth-server
Normal file
Binary file not shown.
BIN
dashboard-server/._ecosystem.config.cjs
Normal file
BIN
dashboard-server/._ecosystem.config.cjs
Normal file
Binary file not shown.
BIN
dashboard-server/._frontend
Normal file
BIN
dashboard-server/._frontend
Normal file
Binary file not shown.
BIN
dashboard-server/._klaviyo-server
Normal file
BIN
dashboard-server/._klaviyo-server
Normal file
Binary file not shown.
BIN
dashboard-server/._klaviyo_fetch_debug.txt
Normal file
BIN
dashboard-server/._klaviyo_fetch_debug.txt
Normal file
Binary file not shown.
BIN
dashboard-server/._pm2.config.js
Normal file
BIN
dashboard-server/._pm2.config.js
Normal file
Binary file not shown.
20
dashboard-server/.env-future
Normal file
20
dashboard-server/.env-future
Normal file
@@ -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
|
||||
BIN
dashboard-server/aircall-server/._.env
Normal file
BIN
dashboard-server/aircall-server/._.env
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._.env.example
Normal file
BIN
dashboard-server/aircall-server/._.env.example
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._README.md
Normal file
BIN
dashboard-server/aircall-server/._README.md
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._logs
Normal file
BIN
dashboard-server/aircall-server/._logs
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._package.json
Normal file
BIN
dashboard-server/aircall-server/._package.json
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._server.js
Normal file
BIN
dashboard-server/aircall-server/._server.js
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/._src
Normal file
BIN
dashboard-server/aircall-server/._src
Normal file
Binary file not shown.
21
dashboard-server/aircall-server/.env.example
Normal file
21
dashboard-server/aircall-server/.env.example
Normal file
@@ -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
|
||||
55
dashboard-server/aircall-server/README.md
Normal file
55
dashboard-server/aircall-server/README.md
Normal file
@@ -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
dashboard-server/aircall-server/package-lock.json
generated
Normal file
1882
dashboard-server/aircall-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
dashboard-server/aircall-server/package.json
Normal file
23
dashboard-server/aircall-server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
83
dashboard-server/aircall-server/server.js
Normal file
83
dashboard-server/aircall-server/server.js
Normal file
@@ -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();
|
||||
BIN
dashboard-server/aircall-server/src/._config
Normal file
BIN
dashboard-server/aircall-server/src/._config
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/._routes
Normal file
BIN
dashboard-server/aircall-server/src/._routes
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/._services
Normal file
BIN
dashboard-server/aircall-server/src/._services
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/._utils
Normal file
BIN
dashboard-server/aircall-server/src/._utils
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/config/._aircall.config.js
Normal file
BIN
dashboard-server/aircall-server/src/config/._aircall.config.js
Normal file
Binary file not shown.
15
dashboard-server/aircall-server/src/config/aircall.config.js
Normal file
15
dashboard-server/aircall-server/src/config/aircall.config.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
BIN
dashboard-server/aircall-server/src/routes/._aircall.routes.js
Normal file
BIN
dashboard-server/aircall-server/src/routes/._aircall.routes.js
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/routes/._index.js
Normal file
BIN
dashboard-server/aircall-server/src/routes/._index.js
Normal file
Binary file not shown.
57
dashboard-server/aircall-server/src/routes/aircall.routes.js
Normal file
57
dashboard-server/aircall-server/src/routes/aircall.routes.js
Normal file
@@ -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;
|
||||
};
|
||||
32
dashboard-server/aircall-server/src/routes/index.js
Normal file
32
dashboard-server/aircall-server/src/routes/index.js
Normal file
@@ -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;
|
||||
};
|
||||
BIN
dashboard-server/aircall-server/src/services/._aircall
Normal file
BIN
dashboard-server/aircall-server/src/services/._aircall
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/services/._base
Normal file
BIN
dashboard-server/aircall-server/src/services/._base
Normal file
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;
|
||||
}
|
||||
}
|
||||
320
dashboard-server/aircall-server/src/services/base/DataManager.js
Normal file
320
dashboard-server/aircall-server/src/services/base/DataManager.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
dashboard-server/aircall-server/src/utils/._db.js
Normal file
BIN
dashboard-server/aircall-server/src/utils/._db.js
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/utils/._logger.js
Normal file
BIN
dashboard-server/aircall-server/src/utils/._logger.js
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/utils/._redis.js
Normal file
BIN
dashboard-server/aircall-server/src/utils/._redis.js
Normal file
Binary file not shown.
BIN
dashboard-server/aircall-server/src/utils/._timeUtils.js
Normal file
BIN
dashboard-server/aircall-server/src/utils/._timeUtils.js
Normal file
Binary file not shown.
15
dashboard-server/aircall-server/src/utils/db.js
Normal file
15
dashboard-server/aircall-server/src/utils/db.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
30
dashboard-server/aircall-server/src/utils/logger.js
Normal file
30
dashboard-server/aircall-server/src/utils/logger.js
Normal file
@@ -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`)
|
||||
})
|
||||
]
|
||||
});
|
||||
}
|
||||
23
dashboard-server/aircall-server/src/utils/redis.js
Normal file
23
dashboard-server/aircall-server/src/utils/redis.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
262
dashboard-server/aircall-server/src/utils/timeUtils.js
Normal file
262
dashboard-server/aircall-server/src/utils/timeUtils.js
Normal file
@@ -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);
|
||||
BIN
dashboard-server/auth-server/._.env
Normal file
BIN
dashboard-server/auth-server/._.env
Normal file
Binary file not shown.
BIN
dashboard-server/auth-server/._.env.example
Normal file
BIN
dashboard-server/auth-server/._.env.example
Normal file
Binary file not shown.
BIN
dashboard-server/auth-server/._index.js
Normal file
BIN
dashboard-server/auth-server/._index.js
Normal file
Binary file not shown.
10
dashboard-server/auth-server/.env.example
Normal file
10
dashboard-server/auth-server/.env.example
Normal file
@@ -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
|
||||
197
dashboard-server/auth-server/index.js
Normal file
197
dashboard-server/auth-server/index.js
Normal file
@@ -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
dashboard-server/auth-server/package-lock.json
generated
Normal file
1044
dashboard-server/auth-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
dashboard-server/auth-server/package.json
Normal file
22
dashboard-server/auth-server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
116
dashboard-server/ecosystem.config.cjs
Normal file
116
dashboard-server/ecosystem.config.cjs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
BIN
dashboard-server/frontend/._build
Normal file
BIN
dashboard-server/frontend/._build
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/._assets
Executable file
BIN
dashboard-server/frontend/build/._assets
Executable file
Binary file not shown.
BIN
dashboard-server/frontend/build/._dashboard.svg
Normal file
BIN
dashboard-server/frontend/build/._dashboard.svg
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/._index.html
Normal file
BIN
dashboard-server/frontend/build/._index.html
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/assets/._index-ClSvHF_l.js
Normal file
BIN
dashboard-server/frontend/build/assets/._index-ClSvHF_l.js
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/assets/._index-ClSvHF_l.js.map
Normal file
BIN
dashboard-server/frontend/build/assets/._index-ClSvHF_l.js.map
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/assets/._index-T9y3LwG-.css
Normal file
BIN
dashboard-server/frontend/build/assets/._index-T9y3LwG-.css
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/assets/._vendor-zehgRTIA.js
Normal file
BIN
dashboard-server/frontend/build/assets/._vendor-zehgRTIA.js
Normal file
Binary file not shown.
BIN
dashboard-server/frontend/build/assets/._vendor-zehgRTIA.js.map
Normal file
BIN
dashboard-server/frontend/build/assets/._vendor-zehgRTIA.js.map
Normal file
Binary file not shown.
497
dashboard-server/frontend/build/assets/index-ClSvHF_l.js
Normal file
497
dashboard-server/frontend/build/assets/index-ClSvHF_l.js
Normal file
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
44
dashboard-server/frontend/build/assets/vendor-zehgRTIA.js
Normal file
44
dashboard-server/frontend/build/assets/vendor-zehgRTIA.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dashboard-server/frontend/build/dashboard.svg
Normal file
1
dashboard-server/frontend/build/dashboard.svg
Normal file
@@ -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 |
15
dashboard-server/frontend/build/index.html
Normal file
15
dashboard-server/frontend/build/index.html
Normal file
@@ -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>
|
||||
BIN
dashboard-server/klaviyo-server/._.env
Normal file
BIN
dashboard-server/klaviyo-server/._.env
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._.gitignore
Normal file
BIN
dashboard-server/klaviyo-server/._.gitignore
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._package-lock.json
generated
Normal file
BIN
dashboard-server/klaviyo-server/._package-lock.json
generated
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._package.json
Normal file
BIN
dashboard-server/klaviyo-server/._package.json
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._routes
Normal file
BIN
dashboard-server/klaviyo-server/._routes
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._server.js
Normal file
BIN
dashboard-server/klaviyo-server/._server.js
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._services
Normal file
BIN
dashboard-server/klaviyo-server/._services
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/._utils
Normal file
BIN
dashboard-server/klaviyo-server/._utils
Normal file
Binary file not shown.
4
dashboard-server/klaviyo-server/.gitignore
vendored
Normal file
4
dashboard-server/klaviyo-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
1950
dashboard-server/klaviyo-server/package-lock.json
generated
Normal file
1950
dashboard-server/klaviyo-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
dashboard-server/klaviyo-server/package.json
Normal file
24
dashboard-server/klaviyo-server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
dashboard-server/klaviyo-server/routes/._events.routes.js
Normal file
BIN
dashboard-server/klaviyo-server/routes/._events.routes.js
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/routes/._index.js
Normal file
BIN
dashboard-server/klaviyo-server/routes/._index.js
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/routes/._metrics.routes.js
Normal file
BIN
dashboard-server/klaviyo-server/routes/._metrics.routes.js
Normal file
Binary file not shown.
429
dashboard-server/klaviyo-server/routes/events.routes.js
Normal file
429
dashboard-server/klaviyo-server/routes/events.routes.js
Normal file
@@ -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;
|
||||
}
|
||||
15
dashboard-server/klaviyo-server/routes/index.js
Normal file
15
dashboard-server/klaviyo-server/routes/index.js
Normal file
@@ -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;
|
||||
}
|
||||
29
dashboard-server/klaviyo-server/routes/metrics.routes.js
Normal file
29
dashboard-server/klaviyo-server/routes/metrics.routes.js
Normal file
@@ -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;
|
||||
}
|
||||
59
dashboard-server/klaviyo-server/server.js
Normal file
59
dashboard-server/klaviyo-server/server.js
Normal file
@@ -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}`);
|
||||
});
|
||||
BIN
dashboard-server/klaviyo-server/services/._events.service.js
Normal file
BIN
dashboard-server/klaviyo-server/services/._events.service.js
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/services/._metrics.service.js
Normal file
BIN
dashboard-server/klaviyo-server/services/._metrics.service.js
Normal file
Binary file not shown.
BIN
dashboard-server/klaviyo-server/services/._redis.service.js
Normal file
BIN
dashboard-server/klaviyo-server/services/._redis.service.js
Normal file
Binary file not shown.
2136
dashboard-server/klaviyo-server/services/events.service.js
Normal file
2136
dashboard-server/klaviyo-server/services/events.service.js
Normal file
File diff suppressed because it is too large
Load Diff
38
dashboard-server/klaviyo-server/services/metrics.service.js
Normal file
38
dashboard-server/klaviyo-server/services/metrics.service.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
214
dashboard-server/klaviyo-server/services/redis.service.js
Normal file
214
dashboard-server/klaviyo-server/services/redis.service.js
Normal file
@@ -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