Phase 1-2 of server consolidation + security hardening
This commit is contained in:
@@ -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 3–5.
|
||||||
|
|
||||||
|
### 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 3–5 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 3–5 can run in parallel; they touch independent services.
|
||||||
|
Phase 6's sub-items can be developed alongside 3–5 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 ≈ 5–7 days (the merge), Phase 5 ≈ 2–3 days, Phase 6 ≈ 3–4 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.
|
||||||
@@ -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
|
|
||||||
-1914
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);
|
|
||||||
});
|
|
||||||
-1044
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1068
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();
|
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { logger, createLogger } from './logger.js';
|
||||||
|
export { requestLog } from './request-log.js';
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
Vendored
-10
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user