Phase 1-2 of server consolidation + security hardening

This commit is contained in:
2026-05-23 17:27:22 -04:00
parent 36f23b527e
commit 1ab14ba45f
46 changed files with 1103 additions and 6826 deletions
+751
View File
@@ -0,0 +1,751 @@
# Server Consolidation & Security Hardening Plan
Audit-driven plan to (a) reduce 12 PM2 processes to 3 application servers + 1 auth server, (b) put every API endpoint behind real authentication, and (c) standardize on ESM across all Node services. Approach is "do it properly the first time" — no half-finished pieces, no deferred cleanup.
---
## Status (2026-05-23)
| Phase | Status | Notes |
|---|---|---|
| 1 — Decommission dead services | **Complete** | aircall/gorgias/clarity/legacy-auth-server deleted from repo + PM2 + Caddyfile + ecosystem.cjs |
| 2 — Build shared `lib/` | **Complete** | Lives at `inventory-server/shared/` (see Deviations). `/verify` endpoint live on auth-server |
| 3 — Convert auth-server + inventory-server to ESM | Not started | Next up. Auth-server is still CJS but has the new `/verify` route added in CJS form |
| 4 — Build `dashboard-server` (the merge) | Not started | klaviyo/meta/google/typeform still run as 4 separate PM2 apps |
| 5 — Convert `acot-server` to ESM | Not started | |
| 6 — Auth hardening | Not started | Shared modules exist (`shared/rate-limit`, `shared/cors`, `shared/logging`) but no service consumes them yet. JWT_SECRET footgun discovered — see 6.4 |
| 7 — Caddyfile final form | Partial | Dead routes removed; `forward_auth` gate + `/uploads/*` gating + per-vendor cleanup deferred to after Phase 4 |
| 8 — ecosystem.config.cjs final form | Partial | Dead apps removed; final shape depends on Phase 4 merge |
**Live PM2 count: 10** (down from 13). Target after Phase 4: 5 application apps + acot-phone-server + lt-wordlist-api.
---
## Goals
- Every public-facing endpoint requires a valid auth token (Caddy gate + per-server middleware + per-route permission checks for sensitive operations).
- Reduce service count from 12 PM2 processes to 4: `inventory-server`, `acot-server`, `dashboard-server`, `auth-server`.
- Standardize on ESM (`"type": "module"`) across all Node services.
- Decommission `aircall-server`, `gorgias-server`, `clarity-server`, and the legacy `auth-server` (port 3003).
- Eliminate dependency duplication: one Redis client, one Postgres pool helper, one logger, one auth middleware — shared across services.
## Non-goals
- Rewriting business logic. Route handlers move as-is unless they break under ESM or shared middleware.
- Switching auth providers (we keep JWT + bcrypt + Postgres).
- Replacing PM2 or Caddy.
- Migrating Klaviyo/Meta/Google/Typeform's external API contracts.
---
## Target architecture
```
┌──────────────────────────┐
│ tools.acherryontop.com │
│ (Caddy) │
│ forward_auth gate ─────┼──► auth-server:3011
└────────────┬─────────────┘ /verify endpoint
┌────────────────────────────────┼────────────────────────────────┐
▼ ▼ ▼
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ inventory-server │ │ dashboard-server │ │ acot-server │
│ :3010 (ESM) │ │ :3015 (ESM) │ │ :3012 (ESM) │
│ │ │ │ │ │
│ /api/products │ │ /api/klaviyo/* │ │ /api/acot/* │
│ /api/orders │ │ /api/meta/* │ │ (MySQL via SSH) │
│ /api/analytics │ │ /api/google-*/* │ │ │
│ /api/dashboard │ │ /api/typeform/* │ │ │
│ ... (~25 routers) │ │ │ │ │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
│ │ │
├── Postgres (inventory_db) ├── Postgres (klaviyo) └── MySQL (workpi, via ssh2 tunnel)
├── shared lib/ ◄────────────────┤
│ - auth middleware ├── Redis (shared client)
│ - permission helper └── shared lib/ ◄─────────────────┐
│ - logger │
│ - pg pool factory │
│ - error formatter │
└─────────────────────────────────────────────────────────────────┘
┌──────────────────┴───┐
│ auth-server │
│ :3011 (ESM) │
│ /login, /me, │
│ /verify, user mgmt │
└──────────────────────┘
```
PM2 process count: **12 → 4** (plus `acot-phone-server` and `lt-wordlist-api`, which stay as-is — out of scope).
---
## Phase 1 — Decommission dead/leaving services
Status: **Complete (2026-05-23)**. All four services removed from repo, PM2, Caddyfile, and ecosystem.config.cjs. Frontend widgets (`AircallDashboard.jsx`, `GorgiasOverview.jsx`) and their dashboard.ts/Navigation.jsx/vite.config.ts wiring also removed. Verification: smoke-tested `https://tools.acherryontop.com/api/{aircall,gorgias,clarity}/*` → 404. Backups left at `/home/matt/{ecosystem.config.cjs,Caddyfile}.bak.2026-05-23`.
### To remove
| Service | Reason | Steps |
|---|---|---|
| `aircall-server` (3002) | Migrating off Aircall | `pm2 delete aircall-server`; remove from `ecosystem.config.cjs`; remove `/api/aircall/*` from Caddyfile; drop `inventory/dashboard/aircall-server/` directory; remove MongoDB connection from any frontend code; cancel Mongo if it was only feeding Aircall |
| `gorgias-server` (3006) | Migrating off Gorgias | same pattern; check frontend for `/api/gorgias/*` callers and delete the dashboards/widgets that use them |
| `clarity-server` (3009) | Already dead (no `.js` files, not in ecosystem) | remove `/api/clarity/*` from Caddyfile; delete `inventory/dashboard/clarity-server/` directory |
| `auth-server` (3003, legacy) | Replaced by `new-auth-server` on 3011 | grep entire codebase for `dashboard-auth` and `localhost:3003`; redirect or remove callers; `pm2 delete auth-server`; remove from ecosystem; remove `/dashboard-auth/*` from Caddyfile; delete `inventory/dashboard/auth-server/` directory |
### Verification before deletion
```bash
# from inventory/ root — find any references before removing
grep -rn "aircall\|/api/aircall" inventory/src/ inventory-server/src/
grep -rn "gorgias\|/api/gorgias" inventory/src/ inventory-server/src/
grep -rn "/dashboard-auth\|localhost:3003" inventory/src/ inventory-server/src/
grep -rn "/api/clarity" inventory/src/ inventory-server/src/
```
Any remaining callers must be deleted or repointed before the server is removed. Do **not** leave a 502 response in production.
### Database/secret cleanup
- Drop the MongoDB instance feeding Aircall (after confirming no other consumers).
- Rotate any Gorgias/Aircall API keys still in `.env` files (defense in depth — they'll be useless soon anyway, but commit hygiene matters).
- Remove `MONGODB_URI`, `AIRCALL_*`, `GORGIAS_*` from any `.env` files.
---
## Phase 2 — Build the shared `lib/`
Status: **Complete (2026-05-23)**. All 11 modules written under `inventory-server/shared/` (NOT repo root — see Deviations). `/verify` endpoint added to auth-server in CJS form (will move to shared/auth/verify.js usage during Phase 3 ESM conversion). Smoke-tested with no-token / bad-token / expired-token / valid-token cases. No service consumes shared/ yet; that happens in Phases 35.
### Location
A single shared directory at the repo root: `shared/` (sibling of `inventory/` and `acot-phone/`). Each service imports from it via a relative path. We do **not** introduce npm workspaces yet — relative imports are fine for three consumers and avoid the npm-link / hoisting headaches.
### Modules to create
```
shared/
├── package.json # "type": "module"
├── auth/
│ ├── middleware.js # authenticate(), requirePermission(), requireAdmin()
│ └── verify.js # verifyToken() — pure function, no Express dependency
├── db/
│ ├── pg.js # createPool(envPrefix) — returns configured Pool
│ └── redis.js # createRedis() — single client, lazy-connect
├── logging/
│ ├── logger.js # pino-based, redacts Authorization/Cookie
│ └── request-log.js # Express middleware, structured access log
├── errors/
│ └── handler.js # consistent error envelope, no leak in prod
├── cors/
│ └── policy.js # single allowed-origins list, exported as cors() options
└── rate-limit/
└── login.js # express-rate-limit config for /login
```
### Auth middleware spec (`shared/auth/middleware.js`)
```js
// Pseudocode — final implementation matches the existing pattern in
// inventory/auth/routes.js authenticate() but factored out.
export function authenticate({ pool }) {
return async (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(header.slice(7), process.env.JWT_SECRET);
// Short-circuit DB hit with an in-memory cache, 60s TTL keyed by token jti
const user = await loadUserCached(pool, decoded.userId);
if (!user.is_active) return res.status(403).json({ error: 'Account inactive' });
req.user = user;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
}
export function requirePermission(code) {
return (req, res, next) => {
if (req.user.is_admin) return next();
if (req.user.permissions?.includes(code)) return next();
res.status(403).json({ error: 'Insufficient permissions' });
};
}
export const requireAdmin = (req, res, next) =>
req.user.is_admin ? next() : res.status(403).json({ error: 'Admin only' });
```
### Why a 60s in-memory user cache
`forward_auth` in Caddy will call `auth-server` on every request. Each per-server `authenticate()` middleware also has a DB lookup to load permissions. Without caching, every API request becomes 1 SQL query for the user row + 1 for permissions. 60s TTL is short enough that deactivating a user takes effect within a minute, long enough that Klaviyo dashboards (which fire dozens of requests on load) don't hammer Postgres.
### Add to `auth-server`: a `/verify` endpoint
Caddy's `forward_auth` only needs "is this token valid? give me a user-id." Today's `/me` does that but with a full permissions join. Add a lightweight `/verify` that:
- Verifies JWT signature only (no DB hit).
- Returns `200` with `X-User-Id` and `X-User-Is-Admin` response headers (which Caddy `copy_headers` will pass to upstream).
- Returns `401` on bad token.
**Decision: each service re-verifies the JWT independently.** Caddy's `forward_auth` is a fast first-pass reject for obviously bad tokens, but the security boundary is the per-server `authenticate()` middleware. Cost is negligible (one HMAC-SHA256 per request); the upside is that a misconfigured Caddyfile can never let an unauthenticated request reach a backend. Upstream services do **not** trust any `X-User-*` headers from Caddy — they parse the `Authorization` header themselves.
---
## Phase 3 — Convert `auth-server` and `inventory-server` to ESM
Status: **Not started.** Lift the easy ones first. These two stay standalone (don't merge into anything), so they're isolated changes. The auth-server's new `/verify` route (added in Phase 2) is currently CJS — refactor it during this phase to import from `../shared/auth/verify.js`.
### Mechanical conversion
Per service:
1. Add `"type": "module"` to `package.json`.
2. Convert `require()``import`. `module.exports``export` / `export default`.
3. Fix `__dirname`/`__filename` (use `import.meta.url` + `fileURLToPath`).
4. Convert any dynamic require (e.g., conditional plugin loading) to `await import()`.
5. Update any sub-imports that don't include the file extension — ESM requires `./foo.js`, not `./foo`.
6. Update `ecosystem.config.cjs` if any service entry depended on CJS semantics. The ecosystem file itself can stay `.cjs` — PM2 reads it as config, doesn't matter what the apps it spawns are.
7. Update nodemon config / scripts.
### Risk areas in inventory-server
- `routes/ai.js` does a lazy init (`aiRouter.initInBackground()` called from `server.js`) — confirm the export shape still works as a default export of an Express router with a sidecar function. May need to split into `export default router; export function initInBackground() {}`.
- Multer setup in `routes/import.js` — straightforward, no ESM-specific concerns.
- SSE setup in `server.js` — moves over cleanly, no module-system entanglement.
- The `child_process.spawn` calls for metrics calculation: ESM doesn't change `child_process` behavior, but if any spawned script uses `require()` of a sibling, that sibling must also be ESM (or stay CJS with a `.cjs` extension).
### Test strategy
- After conversion, `pm2 start ecosystem.config.cjs --only inventory-server` on the server, watch logs for require/import errors at startup.
- Hit `/health`, then the most exercised endpoints (`/api/products`, `/api/dashboard/overview`, `/api/analytics/...`). If startup is clean and three smoke endpoints work, ESM conversion is done. Functional correctness is preserved because no logic changed.
### Auth-server
Already small (~200 LOC server.js + ~few hundred in routes.js + permissions.js). 1-day conversion. Add the new `/verify` endpoint as part of this work.
---
## Phase 4 — Build `dashboard-server` (the merge)
Status: **Not started.** The big merge. Klaviyo + Meta + Google + Typeform → one ESM service. Highest-risk phase — see Rollback strategy for the per-vendor cutover plan.
### Layout
```
inventory/dashboard/
├── server.js # entry: load env, init Postgres+Redis, mount routes, listen
├── package.json # "type": "module", deps from all 4 source servers (deduped)
├── .env # KLAVIYO_*, META_*, GOOGLE_*, TYPEFORM_*, shared DB_*, REDIS_URL
├── routes/
│ ├── klaviyo/ # absorbed from dashboard/klaviyo-server/src/
│ ├── meta/ # absorbed from dashboard/meta-server/
│ ├── google/ # absorbed from dashboard/google-server/
│ └── typeform/ # absorbed from dashboard/typeform-server/
├── services/ # per-vendor API clients (Klaviyo SDK calls, etc.)
├── scripts/
│ └── import-campaign-products.js # one-shot, moved from klaviyo-server/scripts/
└── logs/
```
### Mount points
```js
// server.js (sketch)
import { authenticate, requirePermission } from '../../shared/auth/middleware.js';
import { createPool } from '../../shared/db/pg.js';
import { createRedis } from '../../shared/db/redis.js';
import { logger, requestLog } from '../../shared/logging/index.js';
import corsPolicy from '../../shared/cors/policy.js';
import errorHandler from '../../shared/errors/handler.js';
import klaviyoRouter from './routes/klaviyo/index.js';
import metaRouter from './routes/meta/index.js';
import googleRouter from './routes/google/index.js';
import typeformRouter from './routes/typeform/index.js';
const app = express();
const pool = await createPool('KLAVIYO_DB'); // klaviyo has its own DB; others can share or have none
const redis = await createRedis();
app.use(requestLog);
app.use(cors(corsPolicy));
app.use(express.json({ limit: '10mb' }));
// Everything below this line requires a valid token.
app.use('/api', authenticate({ pool }));
app.use('/api/klaviyo', klaviyoRouter({ pool, redis }));
app.use('/api/meta', metaRouter({ redis }));
app.use('/api/google-analytics', googleRouter({ redis })); // matches Caddy /api/dashboard-analytics rewrite
app.use('/api/typeform', typeformRouter({ redis }));
app.get('/health', (req, res) => res.json({ ok: true }));
app.use(errorHandler);
app.listen(process.env.DASHBOARD_PORT || 3015);
```
### Per-vendor routers
Each vendor's existing route file becomes a factory that takes the shared `pool`/`redis` and returns an Express router. Replace each server's per-instance pool/redis with the injected one.
### Permission gates (sensitive routes only)
Authenticated-only is the default after `app.use('/api', authenticate(...))`. For sensitive operations, add `requirePermission` per route:
- Anything that mutates Klaviyo lists/segments → `requirePermission('klaviyo_write')`
- Triggering a campaign sync → `requirePermission('klaviyo_admin')`
- Read-only dashboards → no extra check beyond authenticate.
Define the new permission codes in the `permissions` table via a migration in Phase 6.
### Dependency dedup
**Decision: standardize on `ioredis`.** Klaviyo's larger codebase already uses it, and `ioredis` has better cluster/sentinel support if we ever need it. Update `meta`/`google`/`typeform` call sites — each is a handful of `get`/`set` calls, mechanical conversion. Remove the `redis` package from `dashboard-server`'s `package.json`.
### Env consolidation
Single `.env` at `inventory/dashboard/.env`, prefixed keys:
```
DASHBOARD_PORT=3015
KLAVIYO_API_KEY=...
KLAVIYO_DB_HOST=...
KLAVIYO_DB_NAME=...
META_ACCESS_TOKEN=...
GOOGLE_SERVICE_ACCOUNT_KEY=...
TYPEFORM_TOKEN=...
REDIS_URL=...
JWT_SECRET=... # shared with auth-server; same secret means same tokens valid here
```
### Klaviyo's `scripts/import-campaign-products.js`
One-shot script — keep it, but run it from the merged dashboard-server's directory. Update the script's imports to ESM. If it's run via cron, update the cron entry to the new path.
### Risk: shared error states
When all four vendors share a Redis client, a Redis hiccup affects all four. Make sure the connection has retry config (`ioredis` defaults are reasonable but verify) and that vendor routes degrade gracefully when Redis is unavailable (most use it as a cache, so cache-miss → fall through to upstream API is the right behavior).
---
## Phase 5 — Convert `acot-server` to ESM (stays standalone)
Status: **Not started.** Largest single conversion (~5K LOC), but no merge involved.
### Special concern: ssh2 tunnel
`acot-server` opens an SSH tunnel via `ssh2` to access the production MySQL at `192.168.1.5:3309`. The tunnel must be:
- Established before the HTTP listener starts (so no requests fail with "no DB connection").
- Re-established on disconnect (`ssh2` connection's `close` event → recreate).
- Cleanly torn down on `SIGTERM`/`SIGINT` so PM2 restarts don't leak file descriptors.
Verify (or add) this lifecycle handling as part of the conversion. If it's already correct, conversion is mechanical; if not, this is a good moment to fix it.
### Test strategy
Same as inventory-server: start with PM2, smoke-test the most-used `/api/acot/*` endpoints, watch logs for unhandled rejection or tunnel-close events.
---
## Phase 6 — Auth hardening
Status: **Not started.** This is the security work that justifies the whole refactor. Runs in parallel with phases 35 where possible. Shared building blocks already exist (`shared/rate-limit/login.js`, `shared/cors/policy.js`, `shared/logging/request-log.js`, `shared/errors/handler.js`) — Phase 6 is about *applying* them per-service.
### 6.1 Caddy `forward_auth` gate
Add to the `tools.acherryontop.com` block, before the `@api_routes` handler:
```caddyfile
# Forward-auth gate for all API traffic
@needs_auth path /api/* /chat-api/*
handle @needs_auth {
forward_auth localhost:3011 {
uri /verify
copy_headers Authorization
# On 401/403, Caddy returns the auth-server's response body verbatim
}
# Existing per-vendor handle blocks remain below this line
}
# /auth-inv/* stays public (you need to log in!)
handle /auth-inv/* {
uri strip_prefix /auth-inv
reverse_proxy localhost:3011
}
```
The `forward_auth` directive subrequests `/verify` on the auth-server. If it returns 2xx, the request proceeds upstream. If 401/403, Caddy returns that response to the client and never hits the backend.
This is the **first** line of defense. Per-server middleware (`shared/auth/middleware.js`) is the **second** line — re-verifies the JWT independently. Defense in depth: a Caddyfile typo can't open a hole.
### 6.2 Per-route permission gates
After per-server `authenticate()`, add `requirePermission(code)` to destructive or sensitive routes. Audit needed in:
- `inventory-server/src/routes/config.js` — global config writes → `admin`
- `inventory-server/src/routes/import.js` — uploads, deletes, generate-upc → `product_import`
- `inventory-server/src/routes/data-management.js` — CSV operations → `data_management`
- `inventory-server/src/routes/ai-prompts.js` — prompt edits → `ai_admin`
- `inventory-server/src/routes/templates.js` — template writes → `templates_write`
- `inventory-server/src/routes/reusable-images.js` — image management → `image_admin`
- `inventory-server/src/routes/products.js` — only one POST (`/resolve-identifiers`); evaluate whether it needs a permission code or authenticated-only is fine
- `inventory-server/src/routes/product-editor-audit-log.js` and `import-audit-log.js` — read-only by sensitive users → `audit_read`
- `dashboard-server` Klaviyo/Meta/Google/Typeform write endpoints → vendor-specific codes per above
Migration: a single SQL script that inserts the new permission codes into the `permissions` table and assigns them to existing admin users. Non-admin users get permissions explicitly granted via the user management UI.
```sql
INSERT INTO permissions (code, name) VALUES
('product_import', 'Product Import'),
('data_management', 'Data Management'),
('ai_admin', 'AI Settings Admin'),
('templates_write', 'Template Editing'),
('image_admin', 'Image Management'),
('audit_read', 'Audit Log Access'),
('klaviyo_write', 'Klaviyo Write'),
('klaviyo_admin', 'Klaviyo Admin'),
('meta_write', 'Meta Write'),
('google_write', 'Google Analytics Write'),
('typeform_write', 'Typeform Write'),
('acot_admin', 'ACOT Server Admin')
ON CONFLICT (code) DO NOTHING;
```
### 6.3 Rate limiting on login
`shared/rate-limit/login.js`:
```js
import rateLimit from 'express-rate-limit';
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per IP per window
message: { error: 'Too many login attempts, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
```
Apply in `auth-server` on the `/login` route. Consider also rate-limiting `/verify` and `/me` (much higher cap, ~600/min — they're called legitimately by every page load).
### 6.4 JWT secret rotation
- Rotate `JWT_SECRET` to a fresh 32-byte random string as part of the deployment.
- Document that rotation logs out all users — acceptable for an internal tool, do it during off-hours.
- Add `JWT_SECRET` to the env var validation block in `auth-server/server.js` (refuse to start if not set).
- **Fix the existing footgun**: `/var/www/ecosystem.config.cjs` currently has `JWT_SECRET: process.env.JWT_SECRET` *after* `...inventoryEnv` in the new-auth-server block. This shadows the `.env` value with whatever the shell exported when PM2 was started — which has already silently diverged at least once (detected and fixed 2026-05-23 by a clean PM2 restart in a shell without JWT_SECRET exported). Delete that override line during rotation; let `.env` be the single source of truth.
### 6.5 Request logging
`shared/logging/request-log.js` — log method, path, status, duration, user-id (if authenticated). **Never** log `Authorization` or `Cookie` headers. Remove the current `server.js:79-87` debug middleware in inventory-server (it logs full headers including the bearer token).
### 6.6 CORS lockdown
Current `middleware/cors.js` allows `192.168.*.*` and `10.*.*.*` with `credentials: true`. Tighten to explicit known origins:
```js
origin: [
'https://tools.acherryontop.com',
'https://inventory.kent.pw',
/^http:\/\/localhost:(5174|5175)$/,
]
```
If anyone genuinely needs LAN access, add their specific IP, not a `/16` range.
### 6.7 Upload hardening
`POST /api/import/upload-image` (multer-backed) needs:
- File-size limit set on multer config (current limit may be defaulted — verify).
- MIME-type allowlist (image/jpeg, image/png, image/webp; reject everything else).
- Filename sanitization (no `..`, no absolute paths, generate UUID-based names server-side).
- The Caddy `/uploads/*` handler currently serves any file in the uploads directory publicly. Move this **behind** the auth gate: include `/uploads/*` in `@needs_auth`. If some images are referenced from public emails (Klaviyo newsletter), put **those** in a separate public bucket; everything else stays gated.
### 6.8 Frontend token storage
**Decision: stay on `localStorage`.** This is an internal tool with no untrusted user-generated HTML being rendered, so the XSS-token-theft surface is small. The `forward_auth` gate is the main security gap we're addressing; cookie-based auth would be a larger, separate project (cookie-parser, CSRF double-submit pattern, AuthContext refactor) that doesn't change the threat model for an internal tool with no public sign-up.
Sanity check during this refactor: grep the React codebase for `dangerouslySetInnerHTML`. If any usages exist, verify each one is rendering trusted (server-controlled, not user-supplied) content. If a user-supplied content path exists, that's a real XSS vector and needs separate remediation regardless of token-storage choice.
### 6.9 Remove debug middleware
[inventory-server/src/server.js:79-87](inventory-server/src/server.js#L79-L87) logs full request headers including `Authorization`. Delete this block. Replace with `shared/logging/request-log.js`.
### 6.10 `lt-wordlist-api` token
`ADD_WORD_TOKEN` is currently hardcoded in `/var/www/ecosystem.config.cjs`. Move to `/opt/lt-wordlist-api/.env`, rotate the token value, update any callers.
### 6.11 Audit logging for sensitive operations
Already have `import-audit-log` and `product-editor-audit-log` tables. Extend the pattern:
- Log `user_id`, `endpoint`, `params`, `result` for `config.js` writes and `data-management.js` operations.
- Schema: reuse the existing audit table pattern or add a generic `system_audit_log` table.
- Don't log request bodies wholesale (may contain large blobs); log the action and the target ID.
---
## Phase 7 — Caddyfile final form
After all phases, the `tools.acherryontop.com` block looks like:
```caddyfile
tools.acherryontop.com {
import security_headers
# Public: login endpoint
handle /auth-inv/* {
uri strip_prefix /auth-inv
reverse_proxy localhost:3011
}
# Public: static frontend assets
@static path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
handle @static {
header Cache-Control "public, max-age=2592000"
root * /var/www/inventory/frontend/build
file_server
}
# All API + uploads: auth gate first
@gated path /api/* /chat-api/* /uploads/*
handle @gated {
forward_auth localhost:3011 {
uri /verify
copy_headers Authorization
}
# Uploaded files
handle /uploads/* {
root * /var/www/inventory
file_server
}
# Vendor dashboard routes → merged dashboard-server
handle /api/klaviyo/* { reverse_proxy localhost:3015 }
handle /api/meta/* { reverse_proxy localhost:3015 }
handle /api/google-analytics/* { reverse_proxy localhost:3015 }
handle /api/typeform/* { reverse_proxy localhost:3015 }
# ACOT-specific
handle /api/acot/* { reverse_proxy localhost:3012 }
# Chat
handle /chat-api/* {
uri strip_prefix /chat-api
reverse_proxy localhost:3014
}
# Catch-all: inventory-server
handle /api/* { reverse_proxy localhost:3010 }
}
handle /health { reverse_proxy localhost:3010 }
# SPA fallback
handle {
root * /var/www/inventory/frontend/build
try_files {path} /index.html
file_server
encode gzip
}
handle_errors {
respond "{err.status_code} {err.status_text}"
}
}
```
Removed: `/dashboard-auth/*`, `/api/aircall/*`, `/api/gorgias/*`, `/api/clarity/*`, the LAN/`Access-Control-Allow-Origin "*"` permissive defaults on `/api/*`. Kept: `/apiv2/*` and `/apiv2-test/*` proxies to backend.acherryontop.com (out of scope, separate system).
---
## Phase 8 — ecosystem.config.cjs final form
```js
module.exports = {
apps: [
{
name: 'auth-server',
script: './inventory/auth/server.js',
cwd: '/var/www',
env: { NODE_ENV: 'production', AUTH_PORT: 3011 },
...commonSettings,
},
{
name: 'inventory-server',
script: './inventory/src/server.js',
cwd: '/var/www',
env: { NODE_ENV: 'production', PORT: 3010, UPLOADS_DIR: '/var/www/inventory/uploads' },
...commonSettings,
},
{
name: 'dashboard-server',
script: './inventory/dashboard/server.js',
cwd: '/var/www',
env: { NODE_ENV: 'production', DASHBOARD_PORT: 3015 },
...commonSettings,
},
{
name: 'acot-server',
script: './inventory/dashboard/acot-server/server.js',
cwd: '/var/www',
env: { NODE_ENV: 'production', ACOT_PORT: 3012 },
...commonSettings,
},
{
name: 'chat-server',
script: './inventory/chat/server.js',
cwd: '/var/www',
env: { NODE_ENV: 'production', PORT: 3014 },
...commonSettings,
},
// acot-phone-server and lt-wordlist-api unchanged
],
};
```
Five entries instead of twelve. Each app loads its own `.env` from its directory (already handled by `dotenv.config`).
---
## Sequencing & dependencies
```
Phase 1 (decommission) ──┬─────────────────────────────────────────┐
│ │
▼ │
Phase 2 (shared lib/) │
│ │
┌──────────────┼──────────────┐ │
▼ ▼ ▼ ▼
Phase 3a Phase 3b Phase 4 Phase 6 (auth hardening
inventory-server auth-server dashboard-server runs alongside 3+4+5,
to ESM to ESM + /verify build & test completes after them)
│ │ │ │
└──────────────┼──────────────┘ │
▼ │
Phase 5 (acot-server to ESM) ──────────────────►│
Phase 7 (Caddy cutover)
Phase 8 (PM2 final state)
```
Phase 1 unblocks everything (fewer services to convert).
Phase 2 is the foundation; nothing else can start until shared `lib/` exists.
Phases 35 can run in parallel; they touch independent services.
Phase 6's sub-items can be developed alongside 35 but **enabled** only after them (no point adding `requirePermission` to a route that doesn't yet have `authenticate`).
Phase 7 is the cutover: Caddyfile flip happens when all backend changes are deployed.
Phase 8 is cleanup: remove dead PM2 entries.
Estimated effort, end-to-end: **~3 weeks of focused work** by one engineer. Phase 1 ≈ 1 day, Phase 2 ≈ 2 days, Phase 3 ≈ 3 days (both services), Phase 4 ≈ 57 days (the merge), Phase 5 ≈ 23 days, Phase 6 ≈ 34 days, Phase 7+8 ≈ 1 day.
---
## Testing strategy
No formal test suite exists today (per CLAUDE.md). For a refactor this size, that's a gap to close — but writing tests retroactively for 15K LOC of routes is a separate, larger project. For this refactor:
### Manual smoke testing per phase
A checklist of representative endpoints to hit after each deploy:
- `inventory-server`: `/api/products`, `/api/dashboard/overview`, `/api/analytics/revenue`, `/api/orders`, `/api/purchase-orders`, `/api/import/list-uploads`, `/api/config/global`
- `dashboard-server`: `/api/klaviyo/campaigns`, `/api/meta/insights`, `/api/google-analytics/...`, `/api/typeform/responses`
- `acot-server`: `/api/acot/...` (top-3 endpoints by call volume — pull from access logs)
- `auth-server`: `/login`, `/me`, `/verify`
Each smoke test runs (a) without a token → expect 401, (b) with an invalid token → expect 401, (c) with a valid token → expect 2xx.
### Frontend integration check
After deploys, log into the SPA and exercise each major page (Overview, Products, Analytics, Dashboard, Klaviyo, Meta, etc.). If everything loads and dashboards populate, the auth + routing layer is intact.
### Test scaffold during Phase 2 (committed)
While building `shared/`, set up `vitest` (lightweight, ESM-native, fast) as the standard test runner for the repo. Initial coverage focuses on the security-critical surface only:
- `shared/auth/verify.js` — known good token, expired token, wrong-signature token, malformed token, missing token.
- `shared/auth/middleware.js` — request with no header → 401; bad header → 401; valid token + inactive user → 403; valid token + missing permission → 403; valid token + correct permission → next() called with `req.user` populated.
- `shared/auth/middleware.js` user-cache TTL: same token within 60s → one DB hit; same token after 61s → two DB hits.
`package.json` gets a `"test": "vitest run"` script at the repo root and per-service. Set up but don't backfill broader test coverage — that's a separate, larger project. The vitest scaffold gives future work a foothold; this refactor commits to having tests for the auth boundary specifically because that's what's load-bearing for the whole security model.
---
## Rollback strategy
Each phase produces an independently deployable state. Rollback per phase:
- **Phase 1**: re-add removed services to ecosystem; restore from git. Don't roll back data deletions — only do those after a week of stable production.
- **Phases 3, 5**: ESM conversion is per-service; if one service breaks, `pm2 restart <name>` to the previous commit. Other services unaffected.
- **Phase 4**: the dashboard-server merge is the highest-risk change. Plan: deploy `dashboard-server` to a non-conflicting port (3015) while leaving the old per-vendor servers running. Cut over Caddy routes one vendor at a time (start with Meta — smallest). If any vendor breaks, point Caddy back to the old server (still running) for that vendor, debug, retry. Only delete the old servers after all four are stable on `dashboard-server`.
- **Phases 6, 7**: Caddy config is git-tracked. `git revert` + `caddy reload` rolls back in seconds. Auth changes are additive (defense in depth) — if `forward_auth` causes problems, comment it out and per-server middleware continues protecting routes.
---
## Out of scope (intentional)
These came up in the audit but aren't part of this refactor:
- `httpOnly` cookie auth (deferred — current `localStorage` acceptable for internal tool).
- Replacing PM2 with systemd or Docker.
- Test coverage beyond the auth-critical surface.
- `apiv2`/`apiv2-test` proxies to `backend.acherryontop.com` — separate system, not touched.
- `acot-phone-server` and `lt-wordlist-api` — staying as-is.
- Centralized observability stack (Prometheus, Grafana). The logger work in Phase 6.5 sets up the data, but shipping it somewhere is future work.
---
## Concrete deliverables
When this is done:
- 4 application PM2 processes instead of 12 (plus 2 unchanged: acot-phone, lt-wordlist).
- All `/api/*` and `/chat-api/*` requests gated at Caddy and re-verified at each upstream.
- Sensitive endpoints additionally gated by per-permission checks.
- One ESM standard across the entire Node codebase.
- One shared `lib/` for auth, logging, DB, errors, CORS.
- Login rate-limited.
- `JWT_SECRET` rotated.
- Old auth-server, Aircall, Gorgias, Clarity directories deleted from the repo.
- Caddyfile slimmed to one auth-gated block.
- Permission codes inserted into `permissions` table for granular authorization.
- No half-finished pieces, no `// TODO: add auth later` comments, no deferred secrets cleanup.
---
## Deviations from original plan (recorded during execution)
These are decisions made during Phase 1/2 implementation that amend the spec above. Future phases should follow the deviated path, not the original sketch.
1. **`shared/` location.** Original plan placed `shared/` at the repo root as a sibling of `inventory/` and `acot-phone/`. Implemented at `inventory-server/shared/` (= `/var/www/inventory/shared/` on the server) instead. Reason: the actual project root *is* `/var/www/inventory/`; placing shared/ outside it would have meant building a deployment story for it that doesn't exist. Import paths change accordingly:
- From `inventory-server/{auth,src,chat}/server.js``../shared/...`
- From `inventory-server/dashboard/{vendor}-server/server.js``../../shared/...`
2. **`/verify` response headers.** Plan specified `X-User-Id` + `X-User-Is-Admin`. Implemented as `X-User-Id` + `X-User-Username` (both available from the JWT payload). `X-User-Is-Admin` was dropped because `is_admin` isn't in the JWT today and returning it would require a DB lookup — violating the "no DB hit" principle. To restore `X-User-Is-Admin`, enrich the JWT payload at login time (one-line change in `auth/routes.js`) during Phase 6, then echo from `/verify`. Upstreams don't trust these headers anyway (they re-verify), so the omission is informational, not security-relevant.
3. **User cache key in `shared/auth/middleware.js`.** Plan sketch mentioned "60s TTL keyed by token jti". Implemented as keyed by `userId` instead — the JWT doesn't currently include a `jti` claim, and the cache's invalidation semantics are "this user was deactivated/changed permissions" (per-user), not "this token was revoked" (per-token). The plan's pseudocode already used `loadUserCached(pool, decoded.userId)` so this matches the spirit.
4. **Redis client safety.** `shared/db/redis.js` sets `enableOfflineQueue: false` and `lazyConnect: true`. Plan didn't specify but these defaults mean a Redis hiccup fails fast (route fall-through to upstream API as designed in Phase 4 risk notes) rather than queueing commands indefinitely.
5. **CORS allowed origins kept `https://acot.site`.** Plan example listed three origins; production has acot.site as a redirect to tools.acherryontop.com but also reaches the API directly in some flows. Kept it to avoid breakage. LAN wildcards (`192.168.*`, `10.*`) and `Access-Control-Allow-Origin "*"` are NOT included in the new `shared/cors/policy.js` per the plan's Phase 6.6 spirit, but the legacy `inventory-server/src/middleware/cors.js` still has them until services are migrated to consume `shared/cors/`.
6. **Defunct permission codes left in DB.** Removed the `dashboard:gorgias` and `dashboard:calls` Protected blocks from the frontend, but the corresponding permission rows in the `permissions` table are still there (assigned to some users). They're inert (no UI references them) but should be cleaned up alongside the Phase 6.2 permissions migration.
7. **PM2 process names retained `new-auth-server` (not `auth-server`).** Plan's Phase 8 final form names it `auth-server` (after the legacy 3003 one is removed). Decided to keep the existing `new-auth-server` name through Phase 2 to avoid a rename mid-stream. Phase 8 can rename if desired, but it's cosmetic — all wiring is by port (3011) not name.
8. **Caddyfile changes via admin API on `:2020`.** The Caddyfile is owned by root and matt has no passwordless sudo. Cutover used `curl -X POST .../load` on the Caddy admin port (which matt can hit), then a separate `sudo cp /home/matt/Caddyfile.new /etc/caddy/Caddyfile` step to persist the on-disk file. Future Caddyfile changes can follow the same pattern. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD`.
9. **Path-naming.** Plan uses `inventory/` as the top-level (server-side path convention). Locally the equivalent is `inventory-server/`. Whenever the plan says `inventory/dashboard/foo/`, read that as `/var/www/inventory/dashboard/foo/` on the server or `inventory-server/dashboard/foo/` locally.
+19
View File
@@ -156,6 +156,25 @@ app.get('/me', async (req, res) => {
} }
}); });
// Caddy forward_auth target: JWT signature check only, no DB hit.
// Returns 200 with X-User-Id / X-User-Username on success, 401 otherwise.
// Per-service middleware re-verifies the token independently; these headers
// are informational and must not be trusted by upstreams.
app.all('/verify', (req, res) => {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(header.slice(7), process.env.JWT_SECRET);
res.set('X-User-Id', String(decoded.userId));
if (decoded.username) res.set('X-User-Username', decoded.username);
res.status(200).end();
} catch (err) {
res.status(401).json({ error: err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token' });
}
});
// Mount all routes from routes.js // Mount all routes from routes.js
app.use('/', authRoutes); app.use('/', authRoutes);
@@ -1,21 +0,0 @@
# 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
@@ -1,55 +0,0 @@
# 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
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
{
"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"
}
}
@@ -1,83 +0,0 @@
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();
@@ -1,15 +0,0 @@
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
}
}
};
@@ -1,57 +0,0 @@
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;
};
@@ -1,32 +0,0 @@
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;
};
@@ -1,298 +0,0 @@
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;
}
}
}
@@ -1,138 +0,0 @@
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,
};
}
}
@@ -1,32 +0,0 @@
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;
}
}
@@ -1,320 +0,0 @@
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;
}
}
@@ -1,15 +0,0 @@
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;
}
}
@@ -1,37 +0,0 @@
import winston from 'winston';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export function createLogger(service) {
// Create logs directory relative to the project root (two levels up from utils)
const logsDir = path.join(__dirname, '../../logs');
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(logsDir, `${service}-error.log`),
level: 'error'
}),
new winston.transports.File({
filename: path.join(logsDir, `${service}-combined.log`)
})
]
});
}
@@ -1,23 +0,0 @@
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;
}
}
@@ -1,262 +0,0 @@
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);
@@ -1,10 +0,0 @@
# 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
@@ -1,203 +0,0 @@
// 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://tools.acherryontop.com'
];
console.log('CORS check for origin:', origin);
// Allow local network IPs (192.168.1.xxx)
if (origin && origin.match(/^http:\/\/192\.168\.1\.\d{1,3}(:\d+)?$/)) {
callback(null, true);
return;
}
// Check if origin is in allowed list
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 local network
const isLocalNetwork = req.headers.origin?.includes('192.168.1.') || req.headers.origin?.includes('localhost');
const cookieOptions = {
httpOnly: true,
secure: !isLocalNetwork, // Only use secure for non-local requests
sameSite: isLocalNetwork ? 'lax' : 'none',
path: '/',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
};
// Only set domain for production
if (!isLocalNetwork) {
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 isLocalNetwork = req.headers.origin?.includes('192.168.1.') || req.headers.origin?.includes('localhost');
const cookieOptions = {
httpOnly: true,
secure: !isLocalNetwork,
sameSite: isLocalNetwork ? 'lax' : 'none',
path: '/',
domain: isLocalNetwork ? 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);
});
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
{
"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"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
{
"name": "gorgias-server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.7.9",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"redis": "^4.7.0"
}
}
@@ -1,119 +0,0 @@
const express = require('express');
const router = express.Router();
const gorgiasService = require('../services/gorgias.service');
// Get statistics
router.post('/stats/:name', async (req, res) => {
try {
const { name } = req.params;
const filters = req.body;
console.log(`Fetching ${name} statistics with filters:`, filters);
if (!name) {
return res.status(400).json({
error: 'Missing statistic name',
details: 'The name parameter is required'
});
}
const data = await gorgiasService.getStatistics(name, filters);
if (!data) {
return res.status(404).json({
error: 'No data found',
details: `No statistics found for ${name}`
});
}
res.json({ data });
} catch (error) {
console.error('Statistics error:', {
name: req.params.name,
filters: req.body,
error: error.message,
stack: error.stack,
response: error.response?.data
});
// Handle specific error cases
if (error.response?.status === 401) {
return res.status(401).json({
error: 'Authentication failed',
details: 'Invalid Gorgias API credentials'
});
}
if (error.response?.status === 404) {
return res.status(404).json({
error: 'Not found',
details: `Statistics type '${req.params.name}' not found`
});
}
if (error.response?.status === 400) {
return res.status(400).json({
error: 'Invalid request',
details: error.response?.data?.message || 'The request was invalid',
data: error.response?.data
});
}
res.status(500).json({
error: 'Failed to fetch statistics',
details: error.response?.data?.message || error.message,
data: error.response?.data
});
}
});
// Get tickets
router.get('/tickets', async (req, res) => {
try {
const data = await gorgiasService.getTickets(req.query);
res.json(data);
} catch (error) {
console.error('Tickets error:', {
params: req.query,
error: error.message,
response: error.response?.data
});
if (error.response?.status === 401) {
return res.status(401).json({
error: 'Authentication failed',
details: 'Invalid Gorgias API credentials'
});
}
if (error.response?.status === 400) {
return res.status(400).json({
error: 'Invalid request',
details: error.response?.data?.message || 'The request was invalid',
data: error.response?.data
});
}
res.status(500).json({
error: 'Failed to fetch tickets',
details: error.response?.data?.message || error.message,
data: error.response?.data
});
}
});
// Get customer satisfaction
router.get('/satisfaction', async (req, res) => {
try {
const data = await gorgiasService.getCustomerSatisfaction(req.query);
res.json(data);
} catch (error) {
console.error('Satisfaction error:', error);
res.status(500).json({
error: 'Failed to fetch customer satisfaction',
details: error.response?.data || error.message
});
}
});
module.exports = router;
@@ -1,31 +0,0 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
require('dotenv').config({
path: path.resolve(__dirname, '.env')
});
const app = express();
const port = process.env.PORT || 3006;
app.use(cors());
app.use(express.json());
// Import routes
const gorgiasRoutes = require('./routes/gorgias.routes');
// Use routes
app.use('/api/gorgias', gorgiasRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
// Start server
app.listen(port, () => {
console.log(`Gorgias API server running on port ${port}`);
});
module.exports = app;
@@ -1,119 +0,0 @@
const axios = require('axios');
const { createClient } = require('redis');
class GorgiasService {
constructor() {
this.redis = createClient({
url: process.env.REDIS_URL
});
this.redis.on('error', err => console.error('Redis Client Error:', err));
this.redis.connect().catch(err => console.error('Redis connection error:', err));
// Create base64 encoded auth string
const auth = Buffer.from(`${process.env.GORGIAS_API_USERNAME}:${process.env.GORGIAS_API_KEY}`).toString('base64');
this.apiClient = axios.create({
baseURL: `https://${process.env.GORGIAS_DOMAIN}.gorgias.com/api`,
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
}
});
}
async getStatistics(name, filters = {}) {
const cacheKey = `gorgias:stats:${name}:${JSON.stringify(filters)}`;
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log(`Statistics ${name} found in Redis cache`);
return JSON.parse(cachedData);
}
console.log(`Fetching ${name} statistics with filters:`, filters);
// Convert dates to UTC midnight if not already set
if (!filters.start_datetime || !filters.end_datetime) {
const start = new Date(filters.start_datetime || filters.start_date);
start.setUTCHours(0, 0, 0, 0);
const end = new Date(filters.end_datetime || filters.end_date);
end.setUTCHours(23, 59, 59, 999);
filters = {
...filters,
start_datetime: start.toISOString(),
end_datetime: end.toISOString()
};
}
// Fetch from API
const response = await this.apiClient.post(`/stats/${name}`, filters);
const data = response.data;
// Save to Redis with 5 minute expiry
await this.redis.set(cacheKey, JSON.stringify(data), {
EX: 300 // 5 minutes
});
return data;
} catch (error) {
console.error(`Error in getStatistics for ${name}:`, {
error: error.message,
filters,
response: error.response?.data
});
throw error;
}
}
async getTickets(params = {}) {
const cacheKey = `gorgias:tickets:${JSON.stringify(params)}`;
try {
// Try Redis first
const cachedData = await this.redis.get(cacheKey);
if (cachedData) {
console.log('Tickets found in Redis cache');
return JSON.parse(cachedData);
}
// Convert dates to UTC midnight
const formattedParams = { ...params };
if (params.start_date) {
const start = new Date(params.start_date);
start.setUTCHours(0, 0, 0, 0);
formattedParams.start_datetime = start.toISOString();
delete formattedParams.start_date;
}
if (params.end_date) {
const end = new Date(params.end_date);
end.setUTCHours(23, 59, 59, 999);
formattedParams.end_datetime = end.toISOString();
delete formattedParams.end_date;
}
// Fetch from API
const response = await this.apiClient.get('/tickets', { params: formattedParams });
const data = response.data;
// Save to Redis with 5 minute expiry
await this.redis.set(cacheKey, JSON.stringify(data), {
EX: 300 // 5 minutes
});
return data;
} catch (error) {
console.error('Error fetching tickets:', {
error: error.message,
params,
response: error.response?.data
});
throw error;
}
}
}
module.exports = new GorgiasService();
+101
View File
@@ -0,0 +1,101 @@
import { extractBearerToken, verifyToken, TokenError } from './verify.js';
const USER_CACHE_TTL_MS = 60_000;
function createUserCache() {
const entries = new Map();
return {
get(userId) {
const hit = entries.get(userId);
if (!hit) return null;
if (Date.now() - hit.cachedAt > USER_CACHE_TTL_MS) {
entries.delete(userId);
return null;
}
return hit.user;
},
set(userId, user) {
entries.set(userId, { user, cachedAt: Date.now() });
},
invalidate(userId) {
entries.delete(userId);
},
};
}
async function loadUser(pool, userId) {
const userResult = await pool.query(
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
if (!user) return null;
if (user.is_admin) {
user.permissions = [];
return user;
}
const permResult = await pool.query(
`SELECT p.code
FROM permissions p
JOIN user_permissions up ON p.id = up.permission_id
WHERE up.user_id = $1`,
[userId]
);
user.permissions = permResult.rows.map((r) => r.code);
return user;
}
export function authenticate({ pool, secret = process.env.JWT_SECRET }) {
const cache = createUserCache();
return async function authenticateMiddleware(req, res, next) {
let decoded;
try {
const token = extractBearerToken(req.headers.authorization);
decoded = verifyToken(token, secret);
} catch (err) {
if (err instanceof TokenError) {
return res.status(401).json({ error: err.message });
}
return res.status(401).json({ error: 'Authentication required' });
}
try {
let user = cache.get(decoded.userId);
if (!user) {
user = await loadUser(pool, decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
cache.set(decoded.userId, user);
}
if (!user.is_active) {
cache.invalidate(decoded.userId);
return res.status(403).json({ error: 'Account inactive' });
}
req.user = user;
next();
} catch (err) {
next(err);
}
};
}
export function requirePermission(code) {
return function requirePermissionMiddleware(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (req.user.is_admin) return next();
if (Array.isArray(req.user.permissions) && req.user.permissions.includes(code)) {
return next();
}
res.status(403).json({ error: 'Insufficient permissions' });
};
}
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (req.user.is_admin) return next();
res.status(403).json({ error: 'Admin only' });
}
+37
View File
@@ -0,0 +1,37 @@
import jwt from 'jsonwebtoken';
export class TokenError extends Error {
constructor(message, code) {
super(message);
this.name = 'TokenError';
this.code = code;
}
}
export function extractBearerToken(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') {
throw new TokenError('No token provided', 'missing');
}
if (!authorizationHeader.startsWith('Bearer ')) {
throw new TokenError('Malformed Authorization header', 'malformed');
}
const token = authorizationHeader.slice(7).trim();
if (!token) {
throw new TokenError('Empty bearer token', 'malformed');
}
return token;
}
export function verifyToken(token, secret) {
if (!secret) {
throw new TokenError('JWT_SECRET not configured', 'misconfigured');
}
try {
return jwt.verify(token, secret);
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new TokenError('Token expired', 'expired');
}
throw new TokenError('Invalid token', 'invalid');
}
}
+24
View File
@@ -0,0 +1,24 @@
export const allowedOrigins = [
'https://tools.acherryontop.com',
'https://inventory.kent.pw',
'https://acot.site',
/^http:\/\/localhost:(5174|5175)$/,
];
export const corsOptions = {
origin(origin, callback) {
if (!origin) return callback(null, true);
const ok = allowedOrigins.some((allowed) =>
allowed instanceof RegExp ? allowed.test(origin) : allowed === origin
);
if (ok) return callback(null, true);
callback(new Error('CORS not allowed'));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Type'],
credentials: true,
maxAge: 600,
};
export default corsOptions;
+19
View File
@@ -0,0 +1,19 @@
import pg from 'pg';
const { Pool } = pg;
export function createPool(envPrefix = 'DB', overrides = {}) {
const get = (key) => process.env[`${envPrefix}_${key}`];
return new Pool({
host: overrides.host ?? get('HOST'),
user: overrides.user ?? get('USER'),
password: overrides.password ?? get('PASSWORD'),
database: overrides.database ?? get('NAME'),
port: overrides.port ?? Number(get('PORT')) || 5432,
ssl: (overrides.ssl ?? get('SSL')) === 'true' ? { rejectUnauthorized: false } : false,
max: overrides.max ?? 20,
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
});
}
+27
View File
@@ -0,0 +1,27 @@
import Redis from 'ioredis';
export function createRedis(overrides = {}) {
const url = overrides.url ?? process.env.REDIS_URL;
const options = {
lazyConnect: true,
maxRetriesPerRequest: 3,
enableOfflineQueue: false,
retryStrategy(times) {
return Math.min(times * 200, 2_000);
},
...overrides,
};
if (url) {
return new Redis(url, options);
}
return new Redis({
host: overrides.host ?? process.env.REDIS_HOST ?? 'localhost',
port: overrides.port ?? Number(process.env.REDIS_PORT) || 6379,
username: overrides.username ?? process.env.REDIS_USERNAME,
password: overrides.password ?? process.env.REDIS_PASSWORD,
...options,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { logger } from '../logging/logger.js';
export function errorHandler(err, req, res, _next) {
const status = err.status ?? err.statusCode ?? 500;
logger.error({
err: { message: err.message, stack: err.stack, code: err.code },
method: req.method,
url: req.url,
userId: req.user?.id,
}, 'request failed');
const body = { error: status >= 500 ? 'Internal server error' : err.message };
if (process.env.NODE_ENV !== 'production' && status >= 500) {
body.detail = err.message;
}
res.status(status).json(body);
}
+2
View File
@@ -0,0 +1,2 @@
export { logger, createLogger } from './logger.js';
export { requestLog } from './request-log.js';
+27
View File
@@ -0,0 +1,27 @@
import { pino } from 'pino';
const REDACTED_PATHS = [
'req.headers.authorization',
'req.headers.cookie',
'headers.authorization',
'headers.cookie',
'*.password',
'*.token',
'*.jwt',
];
export function createLogger(options = {}) {
return pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: {
paths: REDACTED_PATHS,
censor: '[REDACTED]',
},
base: {
service: options.service ?? process.env.SERVICE_NAME ?? 'inventory',
},
...options,
});
}
export const logger = createLogger();
@@ -0,0 +1,32 @@
import { pinoHttp } from 'pino-http';
import { logger } from './logger.js';
export function requestLog(options = {}) {
return pinoHttp({
logger,
customLogLevel(req, res, err) {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
customSuccessMessage(req, res) {
return `${req.method} ${req.url} ${res.statusCode}`;
},
customErrorMessage(req, res, err) {
return `${req.method} ${req.url} ${res.statusCode} ${err?.message ?? ''}`;
},
serializers: {
req(req) {
return {
method: req.method,
url: req.url,
userId: req.raw?.user?.id,
};
},
res(res) {
return { statusCode: res.statusCode };
},
},
...options,
});
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@inventory/shared",
"version": "1.0.0",
"description": "Shared modules used by inventory-server, auth-server, dashboard-server, and acot-server",
"type": "module",
"private": true,
"exports": {
"./auth/middleware": "./auth/middleware.js",
"./auth/verify": "./auth/verify.js",
"./db/pg": "./db/pg.js",
"./db/redis": "./db/redis.js",
"./logging/logger": "./logging/logger.js",
"./logging/request-log": "./logging/request-log.js",
"./logging": "./logging/index.js",
"./errors/handler": "./errors/handler.js",
"./cors/policy": "./cors/policy.js",
"./rate-limit/login": "./rate-limit/login.js"
},
"dependencies": {
"cors": "^2.8.5",
"express-rate-limit": "^7.4.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pino": "^9.5.0",
"pino-http": "^10.3.0"
}
}
@@ -0,0 +1,17 @@
import rateLimit from 'express-rate-limit';
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
export const verifyLimiter = rateLimit({
windowMs: 60 * 1000,
max: 600,
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false,
});
@@ -1,405 +0,0 @@
// components/AircallDashboard.jsx
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
Legend,
ResponsiveContainer,
} from "recharts";
// Import shared components and tokens
import {
DashboardChartTooltip,
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
ChartSkeleton,
CARD_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
import { Phone, Clock, Zap, Timer } from "lucide-react";
// Aircall-specific colors using the standardized palette
const CHART_COLORS = {
inbound: METRIC_COLORS.aov, // Purple for inbound
outbound: METRIC_COLORS.revenue, // Green for outbound
missed: METRIC_COLORS.comparison, // Amber for missed
answered: METRIC_COLORS.revenue, // Green for answered
duration: METRIC_COLORS.orders, // Blue for duration
hourly: METRIC_COLORS.tertiary, // Pink for hourly
};
const TIME_RANGES = [
{ label: "Today", value: "today" },
{ label: "Yesterday", value: "yesterday" },
{ label: "Last 7 Days", value: "last7days" },
{ label: "Last 30 Days", value: "last30days" },
{ label: "Last 90 Days", value: "last90days" },
];
const REFRESH_INTERVAL = 5 * 60 * 1000;
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
};
const AircallDashboard = () => {
const [timeRange, setTimeRange] = useState("last7days");
const [metrics, setMetrics] = useState(null);
const [lastUpdated, setLastUpdated] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [agentSort, setAgentSort] = useState({
key: "total",
direction: "desc",
});
const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
const sortedAgents = metrics?.by_users
? Object.values(metrics.by_users).sort((a, b) => {
const multiplier = agentSort.direction === "desc" ? -1 : 1;
return multiplier * (a[agentSort.key] - b[agentSort.key]);
})
: [];
const chartData = {
hourly: metrics?.by_hour
? metrics.by_hour.map((count, hour) => ({
hour: new Date(2000, 0, 1, hour).toLocaleString('en-US', {
hour: 'numeric',
hour12: true
}).toUpperCase(),
calls: count || 0,
}))
: [],
missedReasons: metrics?.by_missed_reason
? Object.entries(metrics.by_missed_reason).map(([reason, count]) => ({
reason: (reason || "").replace(/_/g, " "),
count: count || 0,
}))
: [],
daily: safeArray(metrics?.daily_data).map((day) => ({
...day,
inbound: day.inbound || 0,
outbound: day.outbound || 0,
date: new Date(day.date).toLocaleString('en-US', {
month: 'short',
day: 'numeric'
}),
})),
};
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "total",
header: "Total Calls",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "answered",
header: "Answered",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-positive">{value}</span>,
},
{
key: "missed",
header: "Missed",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-negative">{value}</span>,
},
{
key: "average_duration",
header: "Avg Duration",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
},
];
// Column definitions for Missed Reasons table
const missedReasonsColumns = [
{
key: "reason",
header: "Reason",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "count",
header: "Count",
align: "right",
render: (value) => <span className="text-trend-negative">{value}</span>,
},
];
const fetchData = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/aircall/metrics/${timeRange}`);
if (!response.ok) throw new Error("Failed to fetch metrics");
const data = await response.json();
setMetrics(data);
setLastUpdated(data._meta?.generatedAt);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [timeRange]);
if (error) {
return (
<Card className={CARD_STYLES.base}>
<CardContent className="p-4">
<DashboardErrorState
title="Failed to load call data"
message={error}
/>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader
title="Calls"
timeSelector={
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Metric Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 dashboard-stagger">
{isLoading ? (
[...Array(4)].map((_, i) => (
<DashboardStatCardSkeleton key={i} hasSubtitle />
))
) : metrics ? (
<>
<DashboardStatCard
title="Total Calls"
value={metrics.total}
subtitle={
<span className="flex gap-3">
<span><span className="text-chart-orders"> {metrics.by_direction.inbound}</span> in</span>
<span><span className="text-chart-revenue"> {metrics.by_direction.outbound}</span> out</span>
</span>
}
icon={Phone}
iconColor="blue"
/>
<DashboardStatCard
title="Answer Rate"
value={`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
subtitle={
<span className="flex gap-3">
<span><span className="text-trend-positive">{metrics.by_status.answered}</span> answered</span>
<span><span className="text-trend-negative">{metrics.by_status.missed}</span> missed</span>
</span>
}
icon={Zap}
iconColor="green"
/>
<DashboardStatCard
title="Peak Hour"
value={
metrics?.by_hour
? new Date(2000, 0, 1, metrics.by_hour.indexOf(Math.max(...metrics.by_hour)))
.toLocaleString('en-US', { hour: 'numeric', hour12: true }).toUpperCase()
: 'N/A'
}
subtitle={`Busiest Agent: ${sortedAgents[0]?.name || "N/A"}`}
icon={Clock}
iconColor="purple"
/>
<DashboardStatCard
title="Avg Duration"
value={formatDuration(metrics.average_duration)}
subtitle={
metrics?.daily_data?.length > 0
? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day`
: "N/A"
}
tooltip={
metrics?.duration_distribution
? `Duration Distribution: ${metrics.duration_distribution.map(d => `${d.range}: ${d.count}`).join(', ')}`
: undefined
}
icon={Timer}
iconColor="teal"
/>
</>
) : null}
</div>
{/* Charts and Tables Section */}
<div className="space-y-4">
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Daily Call Volume */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Daily Call Volume" compact />
<CardContent className="h-[300px]">
{isLoading ? (
<ChartSkeleton type="bar" height="md" withCard={false} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.daily} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/40" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12 }}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
/>
<RechartsTooltip content={<DashboardChartTooltip />} />
<Legend />
<Bar dataKey="inbound" fill={CHART_COLORS.inbound} name="Inbound" radius={[4, 4, 0, 0]} />
<Bar dataKey="outbound" fill={CHART_COLORS.outbound} name="Outbound" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Hourly Distribution */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Hourly Distribution" compact />
<CardContent className="h-[300px]">
{isLoading ? (
<ChartSkeleton type="bar" height="md" withCard={false} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.hourly} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/40" />
<XAxis
dataKey="hour"
tick={{ fontSize: 12 }}
interval={2}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 12 }}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
/>
<RechartsTooltip content={<DashboardChartTooltip />} />
<Bar dataKey="calls" fill={CHART_COLORS.hourly} name="Calls" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
{/* Tables Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Agent Performance */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact />
<CardContent>
<DashboardTable
columns={agentColumns}
data={sortedAgents}
loading={isLoading}
skeletonRows={5}
getRowKey={(agent) => agent.id}
sortConfig={agentSort}
onSort={(key, direction) => setAgentSort({ key, direction })}
maxHeight="md"
compact
/>
</CardContent>
</Card>
{/* Missed Call Reasons Table */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Missed Call Reasons" compact />
<CardContent>
<DashboardTable
columns={missedReasonsColumns}
data={chartData.missedReasons}
loading={isLoading}
skeletonRows={5}
getRowKey={(reason, index) => `${reason.reason}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default AircallDashboard;
@@ -1,407 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import {
Mail,
Send,
ArrowUp,
ArrowDown,
Zap,
Timer,
BarChart3,
ClipboardCheck,
Star,
} from "lucide-react";
import axios from "axios";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
} from "@/components/dashboard/shared";
const TIME_RANGES = {
"today": "Today",
"7": "Last 7 Days",
"14": "Last 14 Days",
"30": "Last 30 Days",
"90": "Last 90 Days",
};
const formatDuration = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
};
const getDateRange = (days) => {
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
if (days === "today") {
const start = new Date(easternTime);
start.setHours(0, 0, 0, 0);
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
return {
start_datetime: start.toISOString(),
end_datetime: end.toISOString()
};
}
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
const start = new Date(easternTime);
start.setDate(start.getDate() - Number(days));
start.setHours(0, 0, 0, 0);
return {
start_datetime: start.toISOString(),
end_datetime: end.toISOString()
};
};
// Trend cell component with arrow and color
const TrendCell = ({ delta }) => {
if (delta === 0) return null;
const isPositive = delta > 0;
const colorClass = isPositive
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
return (
<div className={`flex items-center justify-end gap-0.5 ${colorClass}`}>
{isPositive ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(delta)}%</span>
</div>
);
};
const GorgiasOverview = () => {
const [timeRange, setTimeRange] = useState("7");
const [data, setData] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const loadStats = useCallback(async () => {
setLoading(true);
const filters = getDateRange(timeRange);
try {
const [overview, channelStats, agentStats, satisfaction] =
await Promise.all([
axios.post('/api/gorgias/stats/overview', filters)
.then(res => res.data?.data?.data?.data || []),
axios.post('/api/gorgias/stats/tickets-created-per-channel', filters)
.then(res => res.data?.data?.data?.data?.lines || []),
axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters)
.then(res => res.data?.data?.data?.data?.lines || []),
axios.post('/api/gorgias/stats/satisfaction-surveys', filters)
.then(res => res.data?.data?.data?.data || []),
]);
setData({
overview,
channels: channelStats,
agents: agentStats,
satisfaction,
});
setError(null);
} catch (err) {
console.error("Error loading stats:", err);
const errorMessage = err.response?.data?.error || err.message;
setError(errorMessage);
if (err.response?.status === 401) {
setError('Authentication failed. Please check your Gorgias API credentials.');
}
} finally {
setLoading(false);
}
}, [timeRange]);
useEffect(() => {
loadStats();
const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadStats]);
// Convert overview array to stats format
const stats = (data.overview || []).reduce((acc, item) => {
acc[item.name] = {
value: item.value || 0,
delta: item.delta || 0,
type: item.type,
more_is_better: item.more_is_better
};
return acc;
}, {});
// Process satisfaction data
const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => {
if (item.name !== 'response_distribution') {
acc[item.name] = {
value: item.value || 0,
delta: item.delta || 0,
type: item.type,
more_is_better: item.more_is_better
};
}
return acc;
}, {});
// Process channel data
const channels = (data.channels?.map(line => ({
name: line[0]?.value || '',
total: line[1]?.value || 0,
percentage: line[2]?.value || 0,
delta: line[3]?.value || 0
})) || []).sort((a, b) => b.total - a.total);
// Process agent data
const agents = (data.agents?.map(line => ({
name: line[0]?.value || '',
closed: line[1]?.value || 0,
rating: line[2]?.value,
percentage: line[3]?.value || 0,
delta: line[4]?.value || 0
})) || []).filter(agent => agent.name !== "Unassigned");
// Column definitions for Channel Distribution table
const channelColumns = [
{
key: "name",
header: "Channel",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "total",
header: "Total",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "percentage",
header: "%",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}%</span>,
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "closed",
header: "Closed",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "rating",
header: "Rating",
align: "right",
render: (value) => (
<span className="text-muted-foreground">
{value ? `${value}/5` : "-"}
</span>
),
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
if (error) {
return (
<Card className={`h-full ${CARD_STYLES.base}`}>
<CardContent className="p-4">
<DashboardErrorState
title="Failed to load customer service data"
error={error}
/>
</CardContent>
</Card>
);
}
return (
<Card className={`h-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Customer Service"
timeSelector={
<Select
value={timeRange}
onValueChange={(value) => setTimeRange(value)}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder="Select range">
{TIME_RANGES[timeRange]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{[
["today", "Today"],
["7", "Last 7 Days"],
["14", "Last 14 Days"],
["30", "Last 30 Days"],
["90", "Last 90 Days"],
].map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 2xl:grid-cols-7 gap-4 dashboard-stagger">
{loading ? (
[...Array(7)].map((_, i) => (
<DashboardStatCardSkeleton key={i} />
))
) : (
<>
<DashboardStatCard
title="Messages Received"
value={stats.total_messages_received?.value ?? 0}
trend={stats.total_messages_received?.delta ? {
value: stats.total_messages_received.delta,
suffix: "",
} : undefined}
/>
<DashboardStatCard
title="Messages Sent"
value={stats.total_messages_sent?.value ?? 0}
trend={stats.total_messages_sent?.delta ? {
value: stats.total_messages_sent.delta,
suffix: "",
} : undefined}
/>
<DashboardStatCard
title="First Response"
value={formatDuration(stats.median_first_response_time?.value)}
trend={stats.median_first_response_time?.delta ? {
value: stats.median_first_response_time.delta,
suffix: "",
moreIsBetter: false,
} : undefined}
/>
<DashboardStatCard
title="One-Touch Rate"
value={stats.total_one_touch_tickets?.value ?? 0}
valueSuffix="%"
trend={stats.total_one_touch_tickets?.delta ? {
value: stats.total_one_touch_tickets.delta,
suffix: "%",
} : undefined}
/>
<DashboardStatCard
title="Customer Satisfaction"
value={`${satisfactionStats.average_rating?.value ?? 0}/5`}
trend={satisfactionStats.average_rating?.delta ? {
value: satisfactionStats.average_rating.delta,
suffix: "%",
} : undefined}
/>
<DashboardStatCard
title="Survey Response Rate"
value={satisfactionStats.response_rate?.value ?? 0}
valueSuffix="%"
trend={satisfactionStats.response_rate?.delta ? {
value: satisfactionStats.response_rate.delta,
suffix: "%",
} : undefined}
/>
<DashboardStatCard
title="Resolution Time"
value={formatDuration(stats.median_resolution_time?.value)}
trend={stats.median_resolution_time?.delta ? {
value: stats.median_resolution_time.delta,
suffix: "",
moreIsBetter: false,
} : undefined}
/>
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Channel Distribution */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
<CardContent>
<DashboardTable
columns={channelColumns}
data={channels}
loading={loading}
skeletonRows={5}
getRowKey={(channel, index) => `${channel.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
{/* Agent Performance */}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
<CardContent>
<DashboardTable
columns={agentColumns}
data={agents}
loading={loading}
skeletonRows={5}
getRowKey={(agent, index) => `${agent.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
);
};
export default GorgiasOverview;
@@ -31,8 +31,6 @@ const Navigation = () => {
{ id: "user-behavior", label: "User Behavior", permission: "dashboard:user_behavior" }, { id: "user-behavior", label: "User Behavior", permission: "dashboard:user_behavior" },
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" }, { id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
{ id: "typeform", label: "Customer Surveys", permission: "dashboard:typeform" }, { id: "typeform", label: "Customer Surveys", permission: "dashboard:typeform" },
{ id: "gorgias-overview", label: "Customer Service", permission: "dashboard:gorgias" },
{ id: "calls", label: "Calls", permission: "dashboard:calls" },
]; ];
// Filter sections based on user permissions // Filter sections based on user permissions
-4
View File
@@ -5,15 +5,11 @@ const isLocal = window.location.hostname === 'localhost' || window.location.host
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com'); const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com');
const liveDashboardConfig = { const liveDashboardConfig = {
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
aircall: isDev || useProxy ? '/api/aircall' : 'https://tools.acherryontop.com/api/aircall',
klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://tools.acherryontop.com/api/klaviyo', klaviyo: isDev || useProxy ? '/api/klaviyo' : 'https://tools.acherryontop.com/api/klaviyo',
meta: isDev || useProxy ? '/api/meta' : 'https://tools.acherryontop.com/api/meta', meta: isDev || useProxy ? '/api/meta' : 'https://tools.acherryontop.com/api/meta',
gorgias: isDev || useProxy ? '/api/gorgias' : 'https://tools.acherryontop.com/api/gorgias',
analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://tools.acherryontop.com/api/analytics', analytics: isDev || useProxy ? '/api/dashboard-analytics' : 'https://tools.acherryontop.com/api/analytics',
typeform: isDev || useProxy ? '/api/typeform' : 'https://tools.acherryontop.com/api/typeform', typeform: isDev || useProxy ? '/api/typeform' : 'https://tools.acherryontop.com/api/typeform',
acot: isDev || useProxy ? '/api/acot' : 'https://tools.acherryontop.com/api/acot', acot: isDev || useProxy ? '/api/acot' : 'https://tools.acherryontop.com/api/acot',
clarity: isDev || useProxy ? '/api/clarity' : 'https://tools.acherryontop.com/api/clarity'
}; };
export default liveDashboardConfig; export default liveDashboardConfig;
-12
View File
@@ -1,7 +1,6 @@
import { ScrollProvider } from "@/contexts/DashboardScrollContext"; import { ScrollProvider } from "@/contexts/DashboardScrollContext";
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider"; import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
import { Protected } from "@/components/auth/Protected"; import { Protected } from "@/components/auth/Protected";
import AircallDashboard from "@/components/dashboard/AircallDashboard";
import EventFeed from "@/components/dashboard/EventFeed"; import EventFeed from "@/components/dashboard/EventFeed";
import StatCards from "@/components/dashboard/StatCards"; import StatCards from "@/components/dashboard/StatCards";
import FinancialOverview from "@/components/dashboard/FinancialOverview"; import FinancialOverview from "@/components/dashboard/FinancialOverview";
@@ -9,7 +8,6 @@ import ProductGrid from "@/components/dashboard/ProductGrid";
import SalesChart from "@/components/dashboard/SalesChart"; import SalesChart from "@/components/dashboard/SalesChart";
import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns"; import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns";
import MetaCampaigns from "@/components/dashboard/MetaCampaigns"; import MetaCampaigns from "@/components/dashboard/MetaCampaigns";
import GorgiasOverview from "@/components/dashboard/GorgiasOverview";
import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard"; import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics"; import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard"; import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
@@ -113,16 +111,6 @@ export function Dashboard() {
<TypeformDashboard /> <TypeformDashboard />
</div> </div>
</Protected> </Protected>
<Protected permission="dashboard:gorgias">
<div id="gorgias-overview">
<GorgiasOverview />
</div>
</Protected>
<Protected permission="dashboard:calls">
<div id="calls">
<AircallDashboard />
</div>
</Protected>
</div> </div>
</div> </div>
</div> </div>
-10
View File
@@ -9,11 +9,6 @@ declare module '@/components/dashboard/DateTime' {
export default DateTimeWeatherDisplay; export default DateTimeWeatherDisplay;
} }
declare module '@/components/dashboard/AircallDashboard' {
const AircallDashboard: React.ComponentType<any>;
export default AircallDashboard;
}
declare module '@/components/dashboard/EventFeed' { declare module '@/components/dashboard/EventFeed' {
const EventFeed: React.ComponentType<any>; const EventFeed: React.ComponentType<any>;
export default EventFeed; export default EventFeed;
@@ -44,11 +39,6 @@ declare module '@/components/dashboard/MetaCampaigns' {
export default MetaCampaigns; export default MetaCampaigns;
} }
declare module '@/components/dashboard/GorgiasOverview' {
const GorgiasOverview: React.ComponentType<any>;
export default GorgiasOverview;
}
declare module '@/components/dashboard/AnalyticsDashboard' { declare module '@/components/dashboard/AnalyticsDashboard' {
const AnalyticsDashboard: React.ComponentType<any>; const AnalyticsDashboard: React.ComponentType<any>;
export default AnalyticsDashboard; export default AnalyticsDashboard;
File diff suppressed because one or more lines are too long
-25
View File
@@ -89,12 +89,6 @@ export default defineConfig(({ mode }) => {
secure: true, secure: true,
cookieDomainRewrite: "localhost", cookieDomainRewrite: "localhost",
}, },
"/api/aircall": {
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/klaviyo": { "/api/klaviyo": {
target: "https://tools.acherryontop.com", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
@@ -107,12 +101,6 @@ export default defineConfig(({ mode }) => {
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/gorgias": {
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api/dashboard-analytics": { "/api/dashboard-analytics": {
target: "https://tools.acherryontop.com", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
@@ -133,12 +121,6 @@ export default defineConfig(({ mode }) => {
secure: false, secure: false,
rewrite: (path) => path, rewrite: (path) => path,
}, },
"/api/clarity": {
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
"/api": { "/api": {
target: "https://tools.acherryontop.com", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,
@@ -160,13 +142,6 @@ export default defineConfig(({ mode }) => {
}) })
}, },
}, },
"/dashboard-auth": {
target: "https://tools.acherryontop.com",
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
},
"/auth-inv": { "/auth-inv": {
target: "https://tools.acherryontop.com", target: "https://tools.acherryontop.com",
changeOrigin: true, changeOrigin: true,