Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71cc4dec | |||
| 4be0f877fa | |||
| 82e568d455 | |||
| 1ab14ba45f | |||
| 36f23b527e | |||
| c0f4f1de0d | |||
| 38f4db3d15 | |||
| edfa86608c | |||
| 8721ba67df | |||
| 123946c159 | |||
| 9ab5d4300a | |||
| 338f829eb6 | |||
| c276f165f4 | |||
| 4b2b3d5a9f | |||
| e43abdafd0 | |||
| 54f8cc2706 | |||
| b95bd4a4a0 | |||
| 407731e17d | |||
| e4f5e2c4dd | |||
| 23b94d1c48 | |||
| 9643cf191f | |||
| 76a8836769 | |||
| 884bcbad78 | |||
| f8b81d2111 | |||
| 1b836567cd | |||
| 39b8faa208 | |||
| 177f7778b9 | |||
| f887dc6af1 | |||
| c344fdc3b8 | |||
| ebef903f3b | |||
| 16d2399de8 | |||
| c3e09d5fd1 | |||
| bae8c575bc | |||
| 45ded53530 | |||
| f41b5ab0f6 | |||
| 6834a77a80 | |||
| 38b12c188f | |||
| 6aefc1b40d | |||
| 7c41a7f799 | |||
| 12cc7a4639 | |||
| 9b2f9016f6 | |||
| 8044771301 | |||
| b5469440bf | |||
| fd14af0f9e | |||
| a703019b0b | |||
| 2744e82264 | |||
| 450fd96e19 | |||
| 4372dc5e26 | |||
| dd0e989669 | |||
| 89d518b57f | |||
| ac39257a51 | |||
| 003e1ddd61 | |||
| 2dc8152b53 | |||
| 01d4097030 | |||
| f9e8c9265e | |||
| ee2f314775 | |||
| 11d0555eeb | |||
| ec8ab17d3f | |||
| 100e398aae | |||
| aec02e490a | |||
| 3831cef234 | |||
| 1866cbae7e | |||
| 3d1e8862f9 | |||
| 1dcb47cfc5 | |||
| 167c13c572 | |||
| 7218e7cc3f | |||
| 43d76e011d | |||
| 9ce84fe5b9 | |||
| d15360a7d4 | |||
| 630945e901 | |||
| 54ddaa0492 | |||
| 262890a7be | |||
| ef50aec33c | |||
| 0ffd02e22e | |||
| 738ed94ad5 | |||
| f5b2b4e421 | |||
| b81dfb9649 | |||
| 9be0f34f07 | |||
| ad5b797ce6 | |||
| 78932360d1 | |||
| 217abd41af | |||
| d56beb5143 | |||
| 0b5f3162c7 | |||
| 72930bbc73 | |||
| 0ceef144d7 | |||
| f0e2023803 | |||
| 0a20d74bb6 | |||
| 9761c29934 | |||
| e84c7e568f | |||
| 4953355b91 | |||
| dadcf3b6c6 | |||
| 920c33d119 | |||
| 451d5f0b3b | |||
| dd79298b94 | |||
| 7b7274f72c | |||
| 60875c25a6 | |||
| e10df632d8 | |||
| 945e4a8cc3 | |||
| c6e4fc9cff | |||
| ff17b290aa | |||
| 6bffcfb0a4 | |||
| 2c5255cd13 | |||
| 1696ecf591 | |||
| dc774862a7 | |||
| d3e3cba087 | |||
| 4ea3a4aec3 | |||
| a161f4533d | |||
| 6e30ba60ff | |||
| 138251cf86 | |||
| 24aee1db90 | |||
| 2fe7fd5b2f | |||
| d8b39979cd | |||
| 4776a112b6 | |||
| 2ff325a132 | |||
| 5d46a2a7e5 | |||
| 512b351429 | |||
| 3991341376 | |||
| 5833779c10 | |||
| c61115f665 | |||
| 7da2b304b4 | |||
| 4ccda8ad49 | |||
| 88f703ec70 | |||
| ab998fb7c4 | |||
| faaa8cc47a | |||
| 459c5092d2 | |||
| 6c9fd062e9 | |||
| 5d7d7a8671 | |||
| 54f55b06a1 | |||
| 4935cfe3bb | |||
| 5e2ee73e2d | |||
| 4dfe85231a | |||
| 9e7aac836e | |||
| d35c7dd6cf | |||
| ad1ebeefe1 | |||
| a0c442d1af | |||
| 7938c50762 | |||
| 5dcd19e7f3 | |||
| 075e7253a0 |
@@ -0,0 +1,172 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a full-stack inventory management system with a React + TypeScript frontend and Node.js/Express backend using PostgreSQL. The system includes product management, analytics, forecasting, purchase orders, and a comprehensive dashboard for business metrics.
|
||||||
|
|
||||||
|
**Monorepo Structure:**
|
||||||
|
- `inventory/` - Vite-based React frontend with TypeScript
|
||||||
|
- `inventory-server/` - Express backend API server
|
||||||
|
- Root `package.json` contains shared dependencies
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Frontend (inventory/)
|
||||||
|
```bash
|
||||||
|
cd inventory
|
||||||
|
npm run dev # Start dev server on port 5175
|
||||||
|
npm run build # Build for production (outputs to build/ then copies to ../inventory-server/frontend/build)
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run preview # Preview production build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (inventory-server/)
|
||||||
|
```bash
|
||||||
|
cd inventory-server
|
||||||
|
npm run dev # Start with nodemon (auto-reload)
|
||||||
|
npm start # Start server (production)
|
||||||
|
npm run prod # Start with PM2 for production
|
||||||
|
npm run prod:stop # Stop PM2 instance
|
||||||
|
npm run prod:restart # Restart PM2 instance
|
||||||
|
npm run prod:logs # View PM2 logs
|
||||||
|
npm run setup # Create required directories (logs, uploads)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
|
||||||
|
**Router Structure:** React Router with lazy loading for code splitting:
|
||||||
|
- Main chunks: Core inventory, Dashboard, Product Import, Chat Archive
|
||||||
|
- Authentication flow uses `RequireAuth` and `Protected` components with permission-based access
|
||||||
|
- All routes except `/login` and `/small` require authentication
|
||||||
|
|
||||||
|
**Key Directories:**
|
||||||
|
- `src/pages/` - Top-level page components (Overview, Products, Analytics, Dashboard, etc.)
|
||||||
|
- `src/components/` - Organized by feature (dashboard/, products/, analytics/, etc.)
|
||||||
|
- `src/components/ui/` - shadcn/ui components
|
||||||
|
- `src/types/` - TypeScript type definitions
|
||||||
|
- `src/contexts/` - React contexts (AuthContext, DashboardScrollContext)
|
||||||
|
- `src/hooks/` - Custom React hooks (use-toast, useDebounce, use-mobile)
|
||||||
|
- `src/utils/` - Utility functions (emojiUtils, productUtils, naturalLanguagePeriod)
|
||||||
|
- `src/services/` - API service layer
|
||||||
|
- `src/config/` - Configuration files
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- React Context for auth and global state
|
||||||
|
- @tanstack/react-query for server state management
|
||||||
|
- zustand for client state management
|
||||||
|
- Local storage for auth tokens, session storage for login state
|
||||||
|
|
||||||
|
**Key Dependencies:**
|
||||||
|
- UI: Radix UI primitives, shadcn/ui, Tailwind CSS, Framer Motion
|
||||||
|
- Data: @tanstack/react-table, react-data-grid, @tanstack/react-virtual
|
||||||
|
- Forms: react-hook-form, zod
|
||||||
|
- Charts: recharts, chart.js, react-chartjs-2
|
||||||
|
- File handling: xlsx for Excel export, react-dropzone for uploads
|
||||||
|
- Other: axios for HTTP, date-fns/luxon for dates
|
||||||
|
|
||||||
|
**Path Alias:** `@/` maps to `./src/`
|
||||||
|
|
||||||
|
### Backend Architecture
|
||||||
|
|
||||||
|
**Entry Point:** `inventory-server/src/server.js`
|
||||||
|
|
||||||
|
**Key Directories:**
|
||||||
|
- `src/routes/` - Express route handlers (products, dashboard, analytics, import, etc.)
|
||||||
|
- `src/middleware/` - Express middleware (CORS, auth, etc.)
|
||||||
|
- `src/utils/` - Utility functions (database connection, API helpers)
|
||||||
|
- `src/types/` - Type definitions (e.g., status-codes)
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- PostgreSQL with connection pooling (pg library)
|
||||||
|
- Pool initialized in `utils/db.js` via `initPool()`
|
||||||
|
- Pool attached to `app.locals.pool` for route access
|
||||||
|
- Environment variables loaded from `/var/www/inventory/.env` (production path)
|
||||||
|
|
||||||
|
**API Routes:** All prefixed with `/api/`
|
||||||
|
- `/api/products` - Product CRUD operations
|
||||||
|
- `/api/dashboard` - Dashboard metrics and data
|
||||||
|
- `/api/analytics` - Analytics and reporting
|
||||||
|
- `/api/orders` - Order management
|
||||||
|
- `/api/purchase-orders` - Purchase order management
|
||||||
|
- `/api/csv` - CSV import/export (data management)
|
||||||
|
- `/api/import` - Product import workflows
|
||||||
|
- `/api/config` - Configuration management
|
||||||
|
- `/api/metrics` - System metrics
|
||||||
|
- `/api/ai-validation` - AI-powered validation
|
||||||
|
- `/api/ai-prompts` - AI prompt management
|
||||||
|
- `/api/templates` - Template management
|
||||||
|
- `/api/reusable-images` - Image management
|
||||||
|
- `/api/categoriesAggregate`, `/api/vendorsAggregate`, `/api/brandsAggregate` - Aggregate data endpoints
|
||||||
|
|
||||||
|
**Authentication:**
|
||||||
|
- External auth service at `/auth-inv` endpoint
|
||||||
|
- Token-based authentication (Bearer tokens)
|
||||||
|
- Frontend stores tokens in localStorage
|
||||||
|
- Protected routes verify tokens via auth service `/me` endpoint
|
||||||
|
|
||||||
|
**File Uploads:**
|
||||||
|
- Multer middleware for file handling
|
||||||
|
- Uploads directory: `inventory-server/uploads/`
|
||||||
|
|
||||||
|
### Development Proxy Setup
|
||||||
|
|
||||||
|
The Vite dev server (port 5175) proxies API requests to `https://inventory.kent.pw`:
|
||||||
|
- `/api/*` → production API
|
||||||
|
- `/auth-inv/*` → authentication service
|
||||||
|
- `/chat-api/*` → chat service
|
||||||
|
- `/uploads/*` → uploaded files
|
||||||
|
- Various third-party services (Aircall, Klaviyo, Meta, Gorgias, Typeform, ACOT, Clarity)
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
|
||||||
|
When building the frontend:
|
||||||
|
1. TypeScript compilation (`tsc -b`)
|
||||||
|
2. Vite build (outputs to `inventory/build/`)
|
||||||
|
3. Custom Vite plugin copies build to `inventory-server/frontend/build/`
|
||||||
|
4. Manual chunks for vendor splitting (react-vendor, ui-vendor, query-vendor)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests for individual components or features:
|
||||||
|
```bash
|
||||||
|
# No test suite currently configured
|
||||||
|
# Tests would typically use Jest or Vitest with React Testing Library
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Development Workflows
|
||||||
|
|
||||||
|
### Adding a New Page
|
||||||
|
1. Create page component in `inventory/src/pages/YourPage.tsx`
|
||||||
|
2. Add lazy import in `inventory/src/App.tsx`
|
||||||
|
3. Add route with `<Protected>` wrapper and permission check
|
||||||
|
4. Add corresponding backend route in `inventory-server/src/routes/`
|
||||||
|
5. Update permission system if needed
|
||||||
|
|
||||||
|
### Adding a New API Endpoint
|
||||||
|
1. Create or update route file in `inventory-server/src/routes/`
|
||||||
|
2. Use `executeQuery()` helper for database queries
|
||||||
|
3. Register router in `inventory-server/src/server.js`
|
||||||
|
4. Frontend can access at `/api/{route-name}`
|
||||||
|
|
||||||
|
### Working with Database
|
||||||
|
- Use parameterized queries: `executeQuery(sql, [param1, param2])`
|
||||||
|
- Pool is accessed via `db.getPool()` or `app.locals.pool`
|
||||||
|
- Connection helper: `db.getConnection()` returns a client for transactions
|
||||||
|
|
||||||
|
### Permissions System
|
||||||
|
- User permissions stored in `user.permissions` array (permission codes)
|
||||||
|
- Check permissions in `<Protected page="permission_code">` component
|
||||||
|
- Admin users (`is_admin: true`) have access to all pages
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Environment variables must be configured in `/var/www/inventory/.env` for production
|
||||||
|
- The frontend expects the backend at `/api` (proxied in dev, served together in production)
|
||||||
|
- PM2 is used for production process management
|
||||||
|
- Database uses PostgreSQL with SSL support (configurable via `DB_SSL` env var)
|
||||||
|
- File uploads stored in `inventory-server/uploads/` directory
|
||||||
|
- Build artifacts in `inventory/build/` are copied to `inventory-server/frontend/build/`
|
||||||
+13
@@ -74,3 +74,16 @@ inventory-server/scripts/.fuse_hidden00000fa20000000a
|
|||||||
|
|
||||||
# Ignore compiled Vite config to avoid duplication
|
# Ignore compiled Vite config to avoid duplication
|
||||||
vite.config.js
|
vite.config.js
|
||||||
|
inventory-server/inventory_backup.sql
|
||||||
|
chat-files.tar.gz
|
||||||
|
chat-migration*/
|
||||||
|
**/chat-migration*/
|
||||||
|
chat-migration*/**
|
||||||
|
**/chat-migration*/**
|
||||||
|
|
||||||
|
venv/
|
||||||
|
venv/**
|
||||||
|
**/venv/*
|
||||||
|
**/venv/**
|
||||||
|
|
||||||
|
inventory-server/data/taxonomy-embeddings.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
|
||||||
|
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob
|
||||||
|
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
|
||||||
|
* The postgres/query tool is not working and not connected to the current version of the database. If you need to query the database for any reason you can use "ssh netcup" and use psql on the server with inventory_readonly 6D3GUkxuFgi2UghwgnUd
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
# 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-24)
|
||||||
|
|
||||||
|
| 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 | **Complete** | All 58 server-side files ESM; both services live under the ESM build for >24h. See Deviations #10–13 |
|
||||||
|
| 4 — Build `dashboard-server` (the merge) | **Complete (live) — 2026-05-24** | Merged service running on :3015 under PM2; Caddy routes for klaviyo/meta/dashboard-analytics/typeform all reverse-proxy to it. Old per-vendor directories (`klaviyo-server`, `meta-server`, `google-server`, `typeform-server`) and their PM2 entries deleted post-cutover — ~1.27 GB reclaimed (largely duplicated `node_modules`). Phase 6.2 gates wired (meta_write, klaviyo_admin). See Deviations #16–19 |
|
||||||
|
| 5 — Convert `acot-server` to ESM | Not started | |
|
||||||
|
| 6 — Auth hardening | **Complete** | All in-process items live: rate-limit, JWT precondition, CORS lockdown, request-log, upload allowlist, `requirePermission` on sensitive routes, permissions seed migration. `authenticate()` is live on `/api/*`. 6.11 (audit logging) deferred — see Out of scope |
|
||||||
|
| **F1 — Frontend fetch wrapper** | **Complete (live) — 2026-05-23** | Wrappers at `inventory/src/utils/api.ts` (`apiFetch`) and `inventory/src/utils/apiClient.ts` (axios instance). 170 `fetch()` sites across 76 files migrated to `apiFetch`; 32 `axios.*` sites across 11 files migrated to `apiClient`. AuthContext `/login`+`/me`, App.tsx `/me`, and `services/apiv2.ts` (external PHP backend) intentionally left as raw `fetch`. Shipped alongside the Phase 3+6 pm2 reload |
|
||||||
|
| 7 — Caddyfile final form | **Complete — applied 2026-05-24** | Final Caddyfile live at `/etc/caddy/Caddyfile` (forward_auth gate + per-vendor reverse_proxy to :3015). The `inventory-server/deploy/` staging folder was removed after apply — recreate from this doc if future changes are needed. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD` |
|
||||||
|
| 8 — ecosystem.config.cjs final form | **Complete — applied 2026-05-24** | Live PM2 list matches the spec below (5 apps + acot-phone-server + lt-wordlist-api = 7 processes). Includes Phase 6.4 JWT_SECRET shadow-override fix and 6.10 lt-wordlist token move. `inventory-server/deploy/` removed post-apply |
|
||||||
|
|
||||||
|
**Live PM2 process count: 7** (5 application apps — auth-server, inventory-server, chat-server, dashboard-server, acot-server — plus acot-phone-server + lt-wordlist-api). Down from 13 pre-refactor.
|
||||||
|
|
||||||
|
**All apply steps complete (2026-05-24).** The original sequencing (npm install → F1 ship → pm2 reload → env consolidation → vendor PM2 delete → ecosystem apply → Caddyfile apply) was executed in order. Remaining work is Phase 5 (acot-server ESM conversion) only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: **Complete (live) — 2026-05-24.** Both servers + all sub-trees converted to ESM and running under PM2. 58 importable .js files. Two latent bugs surfaced and fixed during the conversion: `??`/`||` precedence in `shared/db/{pg,redis}.js`, and CJS named-import of `Pool` from `pg` in both auth files (now uses `import pg from 'pg'; const { Pool } = pg`).
|
||||||
|
|
||||||
|
Scripts under `inventory-server/scripts/` (one-shot maintenance / orchestrators) kept CommonJS via a sibling `scripts/package.json` declaring `"type": "commonjs"` — Node's package-type resolution walks up directory by directory, so this overrides the parent's `"type": "module"` without renaming any file or touching any `spawn()` callsite. Convert individual scripts to ESM if/when touched.
|
||||||
|
|
||||||
|
Went live 2026-05-24 after `npm install` on netcup (new deps: `pino`, `pino-http`, `ioredis`, `express-rate-limit`, `jsonwebtoken`) + `pm2 reload`. Phase F1 (frontend fetch wrapper) shipped in the same window so the SPA continued to send `Authorization: Bearer` on every request as `authenticate()` came online.
|
||||||
|
|
||||||
|
### 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: **Complete (live) — 2026-05-24.** Klaviyo + Meta + Google + Typeform merged into a single ESM service at `inventory-server/dashboard/server.js`. Shared Pool + ioredis client injected through router factories. Phase 6.2 permission gates wired (`meta_write` on Meta budget/status mutations; `klaviyo_admin` on Klaviyo `/events/clearCache`). Post-cutover cleanup (2026-05-24) deleted the four old per-vendor directories (`klaviyo-server`, `meta-server`, `google-server`, `typeform-server`) along with their PM2 entries — ~1.27 GB reclaimed, largely duplicated `node_modules` across vendors. Original boot test on netcup: `/health` 200; unauthenticated `/api/klaviyo/*` returns `{"error":"No token provided"}` HTTP 401 via shared `authenticate()`.
|
||||||
|
|
||||||
|
### 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: **Complete (live) — 2026-05-24.** All hardening (in-process + edge) is live in production. The Phase 3 ESM conversion + Phase 6 middleware shipped together, with Phase F1 (frontend fetch wrapper) flipping immediately ahead of the `pm2 reload` so the SPA continued to carry `Authorization: Bearer` on every API call. Caddy `forward_auth` gate and the JWT_SECRET ecosystem fix went live with the Phase 7/8 apply on 2026-05-24.
|
||||||
|
|
||||||
|
Per-item status:
|
||||||
|
|
||||||
|
| # | Item | Status | Where |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 6.1 | Caddy `forward_auth` gate | **Live — 2026-05-24** | Applied via Caddy admin API + `sudo cp` to `/etc/caddy/Caddyfile`. `@gated path /api/* /chat-api/* /uploads/*` block hits `localhost:3011/verify` on every request |
|
||||||
|
| 6.2 | `requirePermission` on sensitive routes + permissions migration | **Done** | inline in `config.js`, `data-management.js`, `import.js`, `ai-prompts.js`, `ai-validation.js`, `templates.js`, `reusable-images.js`; codes seeded by `migrations/005_phase6_permission_codes.sql`. **Phase 4 follow-on (2026-05-23):** `meta_write` wired on `PATCH /api/meta/campaigns/:id/budget` and `POST /api/meta/campaigns/:id/:action`; `klaviyo_admin` wired on `POST /api/klaviyo/events/clearCache`. Read-only Google + Typeform endpoints stay authenticated-only (reserved write codes left in migration 005 for future) |
|
||||||
|
| 6.3 | Login rate-limit + `/verify` rate-limit | **Done** | `auth/server.js` uses `shared/rate-limit/login.js` (`loginLimiter`, `verifyLimiter`) |
|
||||||
|
| 6.4 | JWT_SECRET as startup precondition + ecosystem footgun fix | **Live — 2026-05-24** | Both auth-server and inventory-server `process.exit(1)` if `JWT_SECRET` is unset. The `JWT_SECRET: process.env.JWT_SECRET` override that was shadowing `.env` is removed from the live ecosystem.cjs |
|
||||||
|
| 6.5 | Structured request logging w/ redaction | **Done** | `shared/logging/request-log.js` (pino-http, redacts Authorization/Cookie); mounted in both `auth/server.js` and `src/server.js` |
|
||||||
|
| 6.6 | CORS lockdown | **Done** | `src/middleware/cors.js` now re-exports `shared/cors/policy.js`. LAN wildcards (`192.168.*`, `10.*`) and `*` defaults gone |
|
||||||
|
| 6.7 | Upload hardening | **Done** | Exact-match MIME+extension allowlist on `routes/import.js` and `routes/reusable-images.js`; dead `multer({ dest })` removed from `routes/products.js` (no upload route was using it — strongest hardening was deletion) |
|
||||||
|
| 6.8 | Frontend token storage stays localStorage + XSS audit | **Audited** | Confirmed `dangerouslySetInnerHTML` is sanitized in `ProductEditor.tsx`. **Flagged: `ChatRoom.tsx:277,392` renders user-controlled chat content as raw HTML — real XSS vector, separate fix needed** |
|
||||||
|
| 6.9 | Remove debug middleware | **Done** | The header-dumping `app.use((req,res,next)=>{ console.log(... req.headers ...) })` block removed from `src/server.js`. Replaced with `shared/logging/request-log.js` (which redacts). |
|
||||||
|
| 6.10 | `lt-wordlist-api` token move | **Live — 2026-05-24** | Live PM2 entry runs `/opt/lt-wordlist-api/index.js` under matt's daemon; `ADD_WORD_TOKEN` is no longer inline in ecosystem.cjs and is read from `/opt/lt-wordlist-api/.env`. See Deviations #21–23 for the path corrections and the (incorrect) earlier assumption that this app lived under a separate root daemon |
|
||||||
|
| 6.11 | Audit logging for sensitive ops | **Deferred** | Out of scope for this pass per user direction. Existing `import_audit_log` and `product_editor_audit_log` tables stay as-is; generic `system_audit_log` table + middleware is its own project |
|
||||||
|
|
||||||
|
### 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 F1 — Frontend fetch wrapper (NEW — 2026-05-23)
|
||||||
|
|
||||||
|
Status: **Complete (live) — 2026-05-24.** Two wrappers landed at `inventory/src/utils/api.ts` and `inventory/src/utils/apiClient.ts`. Migration touched 87 files (76 fetch, 11 axios) covering ~200 call sites. Type-check clean; production build clean. Intentional exclusions: AuthContext `/login`+`/me` (own auth flow), App.tsx initial `/me` session check, and `services/apiv2.ts` (calls the separate PHP backend at backend.acherryontop.com which has its own cookie auth, out of scope per the plan). Shipped alongside the Phase 3+6 pm2 reload.
|
||||||
|
|
||||||
|
### The discovery
|
||||||
|
|
||||||
|
While wiring `authenticate()` on `/api/*` in Phase 6.1/6.2, we audited the frontend's fetch usage and found:
|
||||||
|
|
||||||
|
- **7** call sites send `Authorization: Bearer ${token}` explicitly (all in `AuthContext.tsx` for `/me` + `/login`, plus a couple of `settings/*` pages).
|
||||||
|
- **~220** other `fetch(...)` / `axios.*(...)` call sites across `inventory/src/services/`, `inventory/src/pages/`, `inventory/src/components/` send **no** Authorization header at all.
|
||||||
|
- There is no global fetch wrapper, axios interceptor, or service-worker shim that injects the token.
|
||||||
|
|
||||||
|
Today this works because nothing on the server checks. Caddy currently has no `forward_auth` gate (Phase 6.1 is a Caddyfile change that hasn't shipped yet) and the previous inventory-server had no `authenticate()` middleware. The frontend's auth model was "you log in once to get the token; the token is checked only by `/me`; everything else is implicitly trusted at the network layer."
|
||||||
|
|
||||||
|
With Phase 6 code in production, **every page refresh 401s** on the first API call after the next pm2 reload. The user explicitly accepted this when authorising the Phase 6 work — but the fix is its own deliverable, and shipping Phase 3+6 to PM2 without F1 in the same window means an outage window measured in *however long F1 takes* (not minutes).
|
||||||
|
|
||||||
|
### Recommended approach
|
||||||
|
|
||||||
|
Add a single fetch wrapper at `inventory/src/utils/api.ts` (or similar) and migrate the ~220 call sites to use it. The wrapper:
|
||||||
|
|
||||||
|
1. Reads `localStorage.getItem('token')` on every call (cheap; localStorage is sync).
|
||||||
|
2. Merges `Authorization: Bearer ${token}` into the request headers if a token exists.
|
||||||
|
3. Intercepts 401 responses → fires `window.dispatchEvent(new Event('auth:logout'))` (a listener already exists in `AuthContext.tsx:117`) so the user gets bounced to `/login` cleanly instead of seeing broken pages.
|
||||||
|
4. Preserves the existing call shape — `apiFetch(url, init)` should be a drop-in for `fetch(url, init)` so the migration is mechanical.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// inventory/src/utils/api.ts (sketch)
|
||||||
|
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (token && !headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
const res = await fetch(input, { ...init, headers });
|
||||||
|
if (res.status === 401 && token) {
|
||||||
|
// Token expired or revoked — bounce to /login. AuthContext already listens.
|
||||||
|
window.dispatchEvent(new Event('auth:logout'));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape for axios:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// inventory/src/utils/apiClient.ts (sketch)
|
||||||
|
import axios from 'axios';
|
||||||
|
export const apiClient = axios.create();
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(err) => {
|
||||||
|
if (err?.response?.status === 401) window.dispatchEvent(new Event('auth:logout'));
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration plan
|
||||||
|
|
||||||
|
1. Land the two wrapper modules above. ~50 LOC total.
|
||||||
|
2. Codemod or sed-loop: in `inventory/src/`, replace `fetch(` → `apiFetch(` (with the right import) and `axios.get/post/...` → `apiClient.get/post/...`. ~220 call sites — a half-day of careful find-and-replace plus per-page verification. Spot-check the ones with custom `Content-Type` (multipart uploads especially) so the wrapper doesn't clobber multipart boundaries.
|
||||||
|
3. Leave the `AuthContext.tsx` `/login` and `/me` calls alone — they already work and migrating them adds no value.
|
||||||
|
4. Run the SPA: log in, exercise Overview / Products / Analytics / Dashboard / etc. with browser devtools open watching for `Authorization` header on every `/api/*` request.
|
||||||
|
|
||||||
|
### Sequencing with Phase 3+6 deploy
|
||||||
|
|
||||||
|
**Two options:**
|
||||||
|
|
||||||
|
A) **Ship F1 first** (recommended). Frontend goes out with the wrapper; nothing changes server-side. Then `pm2 reload` Phase 3+6. Zero-downtime, zero broken-page window.
|
||||||
|
|
||||||
|
B) **Ship together.** F1 and Phase 3+6 land in the same deploy. Brief window (seconds) where the frontend has the wrapper but the server hasn't reloaded yet — wrapper just sends extra headers the old server ignores. Safe.
|
||||||
|
|
||||||
|
Do **not** ship Phase 3+6 first and F1 second. That gives a broken app for as long as F1 takes.
|
||||||
|
|
||||||
|
### Out of scope (kept on `localStorage`)
|
||||||
|
|
||||||
|
Per Phase 6.8, we're not migrating to httpOnly cookie auth. F1 is the minimum work to make the per-service `authenticate()` (Phase 6) actually usable. A future Phase F2 could move to cookies + CSRF double-submit, but that's a much larger change touching the AuthContext, the login flow, and every backend that reads tokens. Not justified for an internal tool with no public sign-up.
|
||||||
|
|
||||||
|
### Note on `/uploads/*` gating (Phase 6.7's Caddyfile change)
|
||||||
|
|
||||||
|
**Applied as-spec (2026-05-24):** `/uploads/*` is behind `forward_auth` in the live Caddyfile. `<img src="/uploads/...">` references in the SPA are browser-issued GETs that don't carry `Authorization` headers — verify image display works end-to-end (cookies fall-through, signed URLs, or session-bound forward_auth) and if broken, revert this part of 6.7 to keep `/uploads/*` public, OR issue per-image signed URLs from the API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Caddyfile final form
|
||||||
|
|
||||||
|
Status: **Complete — applied 2026-05-24.** Final Caddyfile live at `/etc/caddy/Caddyfile`; vendor handles point at the merged dashboard-server on :3015. The `inventory-server/deploy/` staging folder (which held `Caddyfile.proposed` and the README of apply commands) was removed after apply — recreate from the spec below if future changes are needed. Apply pattern (admin-API `curl -X POST :2020/load` + `sudo cp` to persist on-disk) is captured in Deviation #8. Backup convention: `/etc/caddy/Caddyfile.bak.YYYY-MM-DD`.
|
||||||
|
|
||||||
|
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/dashboard-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
|
||||||
|
|
||||||
|
Status: **Complete — applied 2026-05-24.** Live PM2 list matches the spec below: 5 application apps (auth-server, inventory-server, dashboard-server, acot-server, chat-server) plus acot-phone-server + lt-wordlist-api = 7 total. Includes the Phase 6.4 `JWT_SECRET` shadow-override fix and the Phase 6.10 `lt-wordlist-api` token move. The `inventory-server/deploy/ecosystem.config.cjs.proposed` staging file was removed after apply — recreate from the spec below if future changes are needed.
|
||||||
|
|
||||||
|
```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 F1 must precede the Phase 3+6 pm2 reload** — without the fetch wrapper, the moment the new code goes live the SPA breaks. Discovered during Phase 3+6 implementation; see Phase F1.
|
||||||
|
Phase 7 is the cutover: Caddyfile flip happens after F1 ships AND after the `/uploads/*` gating decision in F1 is made.
|
||||||
|
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 F1 ≈ 0.5–1 day, 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 ("Phase F2" — deferred). Phase F1 keeps `localStorage` + Bearer header because that's the minimum to unblock the Phase 6 `authenticate()` rollout. A future move to cookie auth would touch `AuthContext`, every backend that reads tokens, and introduce CSRF concerns — much larger project.
|
||||||
|
- 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.
|
||||||
|
- ChatRoom XSS remediation (flagged during Phase 6.8 audit — `inventory/src/components/chat/ChatRoom.tsx:277,392` renders user-controlled chat content via `dangerouslySetInnerHTML` without sanitization). Real vulnerability for an internal-but-multi-user tool; separate fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Concrete deliverables
|
||||||
|
|
||||||
|
State as of 2026-05-24: everything below is **shipped** except Phase 5 (acot-server ESM conversion), which is the only remaining work item. Note: the "4 application PM2 processes" original target became **5** in execution because `chat-server` stayed standalone rather than being folded in — never a serious merge candidate (different DB, different protocol shape).
|
||||||
|
|
||||||
|
- ✅ 5 application PM2 processes instead of 12 (auth-server, inventory-server, dashboard-server, acot-server, chat-server) — plus 2 unchanged (acot-phone-server, lt-wordlist-api) = 7 total.
|
||||||
|
- ✅ All `/api/*`, `/chat-api/*`, and `/uploads/*` requests gated at Caddy (`forward_auth`) and re-verified at each upstream (`authenticate()`).
|
||||||
|
- ✅ Sensitive endpoints additionally gated by per-permission checks (`requirePermission`).
|
||||||
|
- ⚠️ One ESM standard — done for auth/inventory/dashboard/chat. **acot-server still CJS (Phase 5 pending).**
|
||||||
|
- ✅ One shared `lib/` at `inventory-server/shared/` for auth, logging, DB, errors, CORS.
|
||||||
|
- ✅ Login rate-limited (`shared/rate-limit/login.js`).
|
||||||
|
- ✅ `JWT_SECRET` rotated + ecosystem shadow-override removed.
|
||||||
|
- ✅ Old auth-server, Aircall, Gorgias, Clarity directories deleted from the repo. Defunct `dashboard:gorgias`/`dashboard:calls` permission rows also deleted from DB (2026-05-24).
|
||||||
|
- ✅ 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 cleaned up (2026-05-24).** Removed the `dashboard:gorgias` and `dashboard:calls` Protected blocks from the frontend; the corresponding permission rows in the `permissions` table (and their user_permission grants) were deleted in a follow-up migration alongside the Phase 6.2 permissions seed. Verified post-migration: `permissions` table contains only the in-use `dashboard:*` codes (analytics, campaigns, feed, financial, meta_campaigns, operations, payroll, products, realtime, sales, stats, typeform, user_behavior).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
10. **Scripts directory kept CJS via package.json shim.** Original plan called for converting "any spawned script" to ESM alongside its caller. Implemented: added `inventory-server/scripts/package.json` with `"type": "commonjs"`. Node's package-type resolution walks up directory by directory, so this overrides the parent's `"type": "module"` for the entire `scripts/` tree (≈15 files including `import/*.js`, `metrics-new/utils/*`, the orchestrator scripts) without renaming any file or touching any `spawn()` callsite. Convert individual scripts to ESM when touched; don't bulk-migrate.
|
||||||
|
|
||||||
|
11. **`src/routes/products.js` had dead multer setup.** Phase 6.7 spec called for hardening the upload route in products.js. There was no upload route — the `multer({ dest })` instance and `importProductsFromCSV` import were dead code left over from a long-ago migration. Strongest 6.7 hardening was deletion: no upload handler = no attack surface. The two real upload paths (`/api/import/upload-image` and `/api/reusable-images/upload`) got tightened MIME+extension allowlists instead.
|
||||||
|
|
||||||
|
12. **Two pre-existing syntax errors in shared/db/ surfaced.** `shared/db/pg.js:13` and `shared/db/redis.js:22` both had `?? Number(...) || N` — mixing `??` and `||` without parentheses is a TC39 syntax error. They passed Phase 2 because nothing imported them yet; Phase 3 smoke-test exposed it. Fixed with parens.
|
||||||
|
|
||||||
|
13. **`import { Pool } from 'pg'` doesn't work in ESM.** The `pg` package is CJS using `module.exports = { Pool, ... }`. Node's ESM-from-CJS interop fails to detect `Pool` as a named export via static analysis. The bulletproof pattern, now used everywhere: `import pg from 'pg'; const { Pool } = pg;`. Same idea for any future CJS-only deps. `src/utils/db.js` already had it; the two auth files needed the fix during execution.
|
||||||
|
|
||||||
|
14. **Frontend Bearer-header gap discovered (drives new Phase F1).** Phase 6 was specified assuming the frontend already sends `Authorization: Bearer` on every API call. It does not — only 7 of ~220 call sites do. Phase 6's `authenticate()` middleware is shipped and ready to enable, but until F1 lands the SPA will 401 on every page. The plan now has Phase F1 to address this explicitly; until then, the Phase 3+6 pm2 reload should not ship unless F1 ships in the same window.
|
||||||
|
|
||||||
|
15. **macOS NFS workflow note.** The `inventory-server/` directory locally is an NFS mount of `/var/www/inventory/` on netcup. Bulk operations (`find`/`grep -r`/mass `node --check`/`npm install`) hang or take minutes locally and pollute file listings with macOS AppleDouble `._*` sidecar files. Default to `ssh netcup` for any sweep across the tree — individual file edits via the editor are fine.
|
||||||
|
|
||||||
|
16. **dashboard-server lives at `inventory-server/dashboard/` (not its own top-level dir).** Plan's Phase 4 diagram implied a sibling of `inventory-server/` etc. The merged service lives at `inventory-server/dashboard/server.js` with `package.json` declaring `"type": "module"`. Per-vendor subdirectories (`klaviyo-server/`, `meta-server/`, `google-server/`, `typeform-server/`) each have their own `package.json` so Node's "nearest parent package.json" walk stops there — they are unaffected by the new parent type. Added `"type": "commonjs"` defensively to meta/google/typeform package.json so a future deletion of their files (cutover cleanup) plus a stray `*.js` left under `dashboard/` wouldn't accidentally try to ESM-parse it.
|
||||||
|
|
||||||
|
17. **Klaviyo `RedisService` kept as a wrapper, but accepts injected client.** Plan said "replace each server's per-instance pool/redis with the injected one." The Klaviyo codebase has ~3K LOC of service code (`events.service.js` alone is 2.2K) that calls `this.redisService._getCacheKey()`, `.get()`, `.set()`, `.getEventData()`, `.clearCache()`, `._getTTL()`. Rewriting all of that to call ioredis directly would risk breaking the cache-key/TTL invariants. Decision: keep `services/klaviyo/redis.service.js` as a thin facade with the same public surface, but its constructor now takes an ioredis client instead of constructing one. The 3 service classes (`EventsService`, `CampaignsService`, `ReportingService`) all take `(apiKey, apiRevision, redis)` and pass `redis` to `new RedisService(redis)`. `MetricsService` doesn't use Redis — left unchanged.
|
||||||
|
|
||||||
|
18. **dashboard-server `.env` layering.** Plan called for "Single `.env` at `inventory/dashboard/.env`, prefixed keys: KLAVIYO_*, META_*, ... JWT_SECRET ... # shared with auth-server." Implemented as two-file layering: `server.js` loads `/var/www/inventory/.env` FIRST (provides JWT_SECRET, DB_*, REDIS_*) then `inventory-server/dashboard/.env` SECOND for vendor-specific keys (KLAVIYO_API_KEY, META_*, GA_*, TYPEFORM_*). dotenv defaults to `override:false`, so the first file wins on collisions — security-critical vars live in one place, vendor keys in the other. `.env.example` template committed at `dashboard/.env.example`. **Pre-cutover step**: copy the vendor keys from the current per-vendor `.env` files into either of those two files before `pm2 reload`, else KLAVIYO_API_KEY etc. will not be set and routes will 500.
|
||||||
|
|
||||||
|
19. **Caddyfile typo fixed: `/api/google-analytics` → `/api/dashboard-analytics`.** The pre-Phase-4 `Caddyfile.proposed` listed a `handle /api/google-analytics/*` block. The live Caddyfile and the frontend (`inventory/src/config/dashboard.ts`) both use `/api/dashboard-analytics/*` (the live file has a `uri replace /api/dashboard-analytics /api/analytics` rewrite to land on google-server's `/api/analytics` mount). The merged dashboard-server now mounts the Google router at `/api/dashboard-analytics` directly — Caddy no longer needs the rewrite, just a straight reverse_proxy. Fixed in `deploy/Caddyfile.proposed`.
|
||||||
|
|
||||||
|
20. **`metrics.routes.js` had a latent router-scope bug.** The Klaviyo `metrics.routes.js` declared `const router = express.Router()` at MODULE scope (outside the `createMetricsRouter` factory), so calling the factory twice would have re-mounted handlers on the same router (cumulative). Benign for a single-mount PM2 service, but fixed during the Phase 4 copy — the router now lives inside the factory. Also renamed the export from `createMetricsRoutes` (plural) to `createMetricsRouter` (matches the convention used by every other vendor's index.js).
|
||||||
|
|
||||||
|
21. **PM2 log paths use per-server `logs/pm2/` (NOT `/var/log/pm2/`).** Discovered during the first apply attempt: the previously-shipped `ecosystem.config.cjs.proposed` carried over `/var/log/pm2/...` from the live file, but matt has no write perms on `/var/log` (root:syslog 775) so the entries silently failed to launch (chat-server + acot-server came up because they had no explicit log path; new-auth-server, inventory-server, dashboard-server bailed). The actual convention — already in place via pre-created folders on disk — is per-service `logs/pm2/` directly under each service's directory (`./inventory/auth/logs/pm2/`, `./inventory/chat/logs/pm2/`, `./inventory/dashboard/acot-server/logs/pm2/`, `./inventory/dashboard/logs/pm2/` for the merged dashboard-server, `./inventory/logs/pm2/` for inventory-server, `/opt/lt-wordlist-api/logs/pm2/`, `/var/www/acot-phone/logs/pm2/`). All folders are matt:matt. `pm2-logrotate` (already loaded in matt's daemon) rotates them in place.
|
||||||
|
|
||||||
|
22. **All PM2 apps run under matt's single daemon — no root daemon.** The earlier `OUT OF SCOPE` comment block in the proposed ecosystem incorrectly claimed `lt-wordlist-api` and `acot-phone-server` were managed by a separate root PM2 daemon. They are not — matt's daemon manages everything. Removed the bogus block; both apps are now first-class entries in the proposed ecosystem with corrected script paths:
|
||||||
|
- `lt-wordlist-api` script is `/opt/lt-wordlist-api/index.js` (was `/opt/lt-wordlist-api/server.js` in the live file — wrong; that file doesn't exist). `/opt/lt-wordlist-api` is matt:matt 0750.
|
||||||
|
- `acot-phone-server` script is `/var/www/acot-phone/dist/server.js` (was `./inventory/acot-phone/server.js` in the live file — wrong; that path doesn't exist). `/var/www/acot-phone/` is matt:matt with its own `.env` and is a separate repo from inventory-server.
|
||||||
|
|
||||||
|
23. **Phase 6.10 ADD_WORD_TOKEN move stays in this ecosystem.** Per Deviation #22, `lt-wordlist-api` is in matt's ecosystem, so the §6.10 work to remove inline `ADD_WORD_TOKEN` and load it from `/opt/lt-wordlist-api/.env` instead is implemented directly in `deploy/ecosystem.config.cjs.proposed` (no inline `ADD_WORD_TOKEN`; script reads its own .env). When applying, rotate the token value in `/opt/lt-wordlist-api/.env` and update any callers.
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# Product Import Module - Enhancement & Issues Outline
|
||||||
|
|
||||||
|
This document outlines the investigation and implementation requirements for each requested enhancement to the product import module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. UPC Import - Strip Quotes and Spaces ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** When importing UPCs, strip `'`, `"` characters and any spaces, leaving only numbers.
|
||||||
|
|
||||||
|
**Implementation (Completed):**
|
||||||
|
- Modified `normalizeUpcValue()` in [Import.tsx:661-667](inventory/src/pages/Import.tsx#L661-L667)
|
||||||
|
- Strips single quotes, double quotes, smart quotes (`'"`), and whitespace before processing
|
||||||
|
- Then handles scientific notation and extracts only digits
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `inventory/src/pages/Import.tsx` - `normalizeUpcValue()` function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AI Context Columns in Validation Payloads ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** The match columns step has a setting to use a field only for AI context (`isAiSupplemental`). Update AI description validation to include any columns selected with this option in the payload. Also include in sanity check payload. Not needed for names.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- AI Supplemental toggle: [MatchColumnsStep.tsx:102-118](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L102-L118)
|
||||||
|
- AI supplemental data stored in `__aiSupplemental` field on each row
|
||||||
|
- Description payload builder: [inlineAiPayload.ts:183-195](inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts#L183-L195)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. **Update `buildDescriptionValidationPayload()` in `inlineAiPayload.ts`** to include AI supplemental data:
|
||||||
|
```typescript
|
||||||
|
export const buildDescriptionValidationPayload = (
|
||||||
|
row: Data<string>,
|
||||||
|
fieldOptions: FieldOptionsMap,
|
||||||
|
productLinesCache: Map<string, SelectOption[]>,
|
||||||
|
sublinesCache: Map<string, SelectOption[]>
|
||||||
|
) => {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
company_name: getFieldOptionLabel(row.company, fieldOptions, 'company'),
|
||||||
|
company_id: row.company,
|
||||||
|
categories: getFieldOptionLabel(row.category, fieldOptions, 'category'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add AI supplemental context if present
|
||||||
|
if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') {
|
||||||
|
payload.additional_context = row.__aiSupplemental;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update sanity check payload** - Locate sanity check submission logic and include `__aiSupplemental` data
|
||||||
|
|
||||||
|
3. **Verify `__aiSupplemental` is properly populated** from MatchColumnsStep when columns are marked as AI context only
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts`
|
||||||
|
- Backend sanity check endpoint (if separate from description validation)
|
||||||
|
- Verify data flow in `MatchColumnsStep.tsx` → `ValidationStep`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fresh Taxonomy Data Per Session ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** Ensure taxonomy data is brought in fresh with each session - cache should be invalidated if we exit the import flow and start again.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Field options cached 5 minutes: [ValidationStep/index.tsx:128-133](inventory/src/components/product-import/steps/ValidationStep/index.tsx#L128-L133)
|
||||||
|
- Product lines cache: `productLinesCache` in Zustand store
|
||||||
|
- Sublines cache: `sublinesCache` in Zustand store
|
||||||
|
- Caches set to 10-minute stale time
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. **Add cache invalidation on import flow mount/unmount** in `UploadFlow.tsx`:
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
// On mount - invalidate import-related query cache
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['import-field-options'] });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// On unmount - clear caches
|
||||||
|
queryClient.removeQueries({ queryKey: ['import-field-options'] });
|
||||||
|
queryClient.removeQueries({ queryKey: ['product-lines'] });
|
||||||
|
queryClient.removeQueries({ queryKey: ['sublines'] });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clear Zustand store caches** when exiting import flow:
|
||||||
|
- Add action to `validationStore.ts` to clear `productLinesCache` and `sublinesCache`
|
||||||
|
- Call this action on unmount of `UploadFlow` or when navigating away
|
||||||
|
|
||||||
|
3. **Consider adding a `sessionId`** that changes on each import flow start, used as part of cache keys
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `inventory/src/components/product-import/steps/UploadFlow.tsx` - Add cleanup effect
|
||||||
|
- `inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts` - Add cache clear action
|
||||||
|
- Potentially `inventory/src/components/product-import/steps/ValidationStep/index.tsx` - Query key updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Save Template from Confirmation Page ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** Add option to save rows of submitted data as a new template on the confirmation page after completing the import flow. Verify this works with new validation step changes.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- **Import Results section already exists** inline in [Import.tsx:968-1150](inventory/src/pages/Import.tsx#L968-L1150)
|
||||||
|
- Shows created products (lines 1021-1097) with image, name, UPC, item number
|
||||||
|
- Shows errored products (lines 1100-1138) with error details
|
||||||
|
- "Fix products with errors" button resumes validation flow for failed items
|
||||||
|
- Template saving logic in ValidationStep: [useTemplateManagement.ts:204-266](inventory/src/components/product-import/steps/ValidationStep/hooks/useTemplateManagement.ts#L204-L266)
|
||||||
|
- Saves via `POST /api/templates`
|
||||||
|
- `importOutcome.submittedProducts` contains the full product data for each row
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. **Add "Save as Template" button** to each created product row in the results section (around line 1087-1092 in Import.tsx):
|
||||||
|
```typescript
|
||||||
|
// Add button after the item number display
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSaveAsTemplate(index)}
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add state and dialog** for template saving in Import.tsx:
|
||||||
|
```typescript
|
||||||
|
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
|
||||||
|
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Extract/reuse template save logic** from `useTemplateManagement.ts`:
|
||||||
|
- The `saveNewTemplate()` function (lines 204-266) can be extracted into a shared utility
|
||||||
|
- Or create a `SaveTemplateDialog` component that can be used in both places
|
||||||
|
- Key fields needed: `company` (for template name), `product_type`, and all product field values
|
||||||
|
|
||||||
|
4. **Data mapping consideration:**
|
||||||
|
- `importOutcome.submittedProducts` uses `NormalizedProduct` type
|
||||||
|
- Templates expect raw field values - may need to map back from normalized format
|
||||||
|
- Exclude metadata fields: `['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes', '__aiSupplemental']`
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `inventory/src/pages/Import.tsx` - Add save template button, state, and dialog
|
||||||
|
- Consider creating `inventory/src/components/product-import/SaveTemplateDialog.tsx` for reusability
|
||||||
|
- Potentially extract core save logic from `useTemplateManagement.ts` into shared utility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sheet Preview on Select Sheet Step ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** On the select sheet step, show a preview of the first 10 lines or so of each sheet underneath the options.
|
||||||
|
|
||||||
|
**Implementation (Completed):**
|
||||||
|
- Added `workbook` prop to `SelectSheetStep` component
|
||||||
|
- Added `sheetPreviews` memoized computation using `XLSXLib.utils.sheet_to_json()`
|
||||||
|
- Shows first 10 rows, 8 columns max per sheet
|
||||||
|
- Added `truncateCell()` helper to limit cell content to 30 characters with ellipsis
|
||||||
|
- Each sheet option is now a clickable card with:
|
||||||
|
- Radio button and sheet name
|
||||||
|
- Row count indicator
|
||||||
|
- Scrollable preview table with horizontal scroll
|
||||||
|
- Selected state highlighted with primary border
|
||||||
|
- Updated `UploadFlow.tsx` to pass workbook prop
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `inventory/src/components/product-import/steps/SelectSheetStep/SelectSheetStep.tsx`
|
||||||
|
- `inventory/src/components/product-import/steps/UploadFlow.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empty Row Removal ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** When importing a sheet, automatically remove completely empty rows.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Empty columns are filtered: [MatchColumnsStep.tsx:616-634](inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx#L616-L634)
|
||||||
|
- A "Remove empty/duplicates" button exists that removes empty rows, single-value rows, AND duplicates
|
||||||
|
- The automatic removal should ONLY remove completely empty rows, not duplicates or single-value rows
|
||||||
|
|
||||||
|
**Implementation (Completed):**
|
||||||
|
- Added `isRowCompletelyEmpty()` helper function to [SelectHeaderStep.tsx](inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx)
|
||||||
|
- Added `useMemo` to filter empty rows on initial data load
|
||||||
|
- Uses `Object.values(row)` to check all cell values (matches existing button logic)
|
||||||
|
- Only removes rows where ALL values are undefined, null, or whitespace-only strings
|
||||||
|
- Manual "Remove Empty/Duplicates" button still available for additional cleanup (duplicates, single-value rows)
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `inventory/src/components/product-import/steps/SelectHeaderStep/SelectHeaderStep.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Unit Conversion for Weight/Dimensions ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** Add unit conversion feature for weight and dimensions columns - similar to calculator button on cost/msrp, add button that opens popover with options to convert grams → oz, lbs → oz for the whole column at once.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Calculator button on price columns: [ValidationTable.tsx:1491-1627](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1491-L1627)
|
||||||
|
- `PriceColumnHeader` component shows calculator icon on hover
|
||||||
|
- Weight field defined in config with validation
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. **Create `UnitConversionColumnHeader` component** (similar to `PriceColumnHeader`):
|
||||||
|
```typescript
|
||||||
|
const UnitConversionColumnHeader = ({ field, table }) => {
|
||||||
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
|
|
||||||
|
const conversions = {
|
||||||
|
weight: [
|
||||||
|
{ label: 'Grams → Ounces', factor: 0.035274 },
|
||||||
|
{ label: 'Pounds → Ounces', factor: 16 },
|
||||||
|
{ label: 'Kilograms → Ounces', factor: 35.274 },
|
||||||
|
],
|
||||||
|
dimensions: [
|
||||||
|
{ label: 'Centimeters → Inches', factor: 0.393701 },
|
||||||
|
{ label: 'Millimeters → Inches', factor: 0.0393701 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyConversion = (factor: number) => {
|
||||||
|
// Batch update all cells in column
|
||||||
|
table.rows.forEach((row, index) => {
|
||||||
|
const currentValue = parseFloat(row[field.key]);
|
||||||
|
if (!isNaN(currentValue)) {
|
||||||
|
updateCell(index, field.key, (currentValue * factor).toFixed(2));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={showPopover} onOpenChange={setShowPopover}>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Scale className="h-4 w-4" /> {/* or similar icon */}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent>
|
||||||
|
{conversions[fieldType].map(conv => (
|
||||||
|
<Button key={conv.label} onClick={() => applyConversion(conv.factor)}>
|
||||||
|
{conv.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Identify weight/dimension fields** in config:
|
||||||
|
- `weight_oz`, `length_in`, `width_in`, `height_in` (check actual field keys)
|
||||||
|
|
||||||
|
3. **Add to column header render logic** in ValidationTable
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx`
|
||||||
|
- Potentially create new component file for `UnitConversionColumnHeader`
|
||||||
|
- Update column header rendering to use new component for weight/dimension fields
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Expanded MSRP Auto-Fill from Cost ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** Expand auto-fill functionality for MSRP from cost - open small popover with options for 2x, 2.1x, 2.2x, 2.3x, 2.4x, 2.5x multipliers, plus checkbox to round up to nearest 9.
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Calculator on MSRP column: [ValidationTable.tsx:1540-1584](inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx#L1540-L1584)
|
||||||
|
- Currently only does `Cost × 2` then subtracts 0.01 if whole number
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
1. **Replace simple click with popover** in `PriceColumnHeader`:
|
||||||
|
```typescript
|
||||||
|
const [selectedMultiplier, setSelectedMultiplier] = useState(2.0);
|
||||||
|
const [roundToNine, setRoundToNine] = useState(false);
|
||||||
|
const multipliers = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
|
||||||
|
|
||||||
|
const roundUpToNine = (value: number): number => {
|
||||||
|
// 1.41 → 1.49, 2.78 → 2.79, 12.32 → 12.39
|
||||||
|
const wholePart = Math.floor(value);
|
||||||
|
const decimal = value - wholePart;
|
||||||
|
if (decimal <= 0.09) return wholePart + 0.09;
|
||||||
|
if (decimal <= 0.19) return wholePart + 0.19;
|
||||||
|
// ... continue pattern, or:
|
||||||
|
const lastDigit = Math.floor(decimal * 10);
|
||||||
|
return wholePart + (lastDigit / 10) + 0.09;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateMsrp = (cost: number): number => {
|
||||||
|
let result = cost * selectedMultiplier;
|
||||||
|
if (roundToNine) {
|
||||||
|
result = roundUpToNine(result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create popover UI**:
|
||||||
|
```tsx
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger><Calculator className="h-4 w-4" /></PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Multiplier</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{multipliers.map(m => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
variant={selectedMultiplier === m ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedMultiplier(m)}
|
||||||
|
>
|
||||||
|
{m}x
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={roundToNine} onCheckedChange={setRoundToNine} />
|
||||||
|
<Label>Round to .X9</Label>
|
||||||
|
</div>
|
||||||
|
<Button onClick={applyCalculation} className="w-full">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files to Modify:**
|
||||||
|
- `inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx` - `PriceColumnHeader` component
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Debug Mode - Skip API Submission ✅ IMPLEMENTED
|
||||||
|
|
||||||
|
**Issue:** Add a third switch in the footer of image upload step (visible only to users with `admin:debug` permission) that will not submit data to any API, only complete the process and show results page as if it had worked.
|
||||||
|
|
||||||
|
**Implementation (Completed):**
|
||||||
|
- Added `skipApiSubmission` state to `ImageUploadStep.tsx`
|
||||||
|
- Added amber-colored "Skip API (Debug)" switch (visible only with `admin:debug` permission)
|
||||||
|
- When skip is active, "Use Test API" and "Use Test Database" switches are hidden
|
||||||
|
- Added `skipApiSubmission?: boolean` to `SubmitOptions` type in `types.ts`
|
||||||
|
- In `Import.tsx`, when `skipApiSubmission` is true:
|
||||||
|
- Skips the actual API call entirely
|
||||||
|
- Generates mock success response with mock PIDs
|
||||||
|
- Shows `[DEBUG]` prefix in toast and result message
|
||||||
|
- Displays results page as if submission succeeded
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `inventory/src/components/product-import/types.ts` - Added `skipApiSubmission` to `SubmitOptions`
|
||||||
|
- `inventory/src/components/product-import/steps/ImageUploadStep/ImageUploadStep.tsx` - Added switch UI
|
||||||
|
- `inventory/src/pages/Import.tsx` - Added skip logic in `handleData()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| # | Enhancement | Complexity | Status |
|
||||||
|
|---|-------------|------------|--------|
|
||||||
|
| 1 | Strip UPC quotes/spaces | Low | ✅ Implemented |
|
||||||
|
| 2 | AI context in validation | Medium | ✅ Implemented |
|
||||||
|
| 3 | Fresh taxonomy per session | Medium | ✅ Implemented |
|
||||||
|
| 4 | Save template from confirmation | Medium-High | ✅ Implemented |
|
||||||
|
| 5 | Sheet preview | Low-Medium | ✅ Implemented |
|
||||||
|
| 6 | Remove empty rows | Low | ✅ Implemented |
|
||||||
|
| 7 | Unit conversion | Medium | ✅ Implemented |
|
||||||
|
| 8 | MSRP multiplier options | Medium | ✅ Implemented |
|
||||||
|
| 9 | Debug skip API | Low-Medium | ✅ Implemented |
|
||||||
|
|
||||||
|
**Implemented:** 9 of 9 items - All enhancements complete!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document generated: 2026-01-25*
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# Metrics Calculation Pipeline Audit
|
||||||
|
|
||||||
|
**Date:** 2026-02-07
|
||||||
|
**Scope:** All 6 SQL calculation scripts, custom DB functions, import pipeline, and live data verification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The metrics pipeline in `inventory-server/scripts/calculate-metrics-new.js` runs 6 SQL scripts sequentially:
|
||||||
|
|
||||||
|
1. `update_daily_snapshots.sql` — Aggregates daily per-product sales/receiving data
|
||||||
|
2. `update_product_metrics.sql` — Calculates the main product_metrics table (KPIs, forecasting, status)
|
||||||
|
3. `update_periodic_metrics.sql` — ABC classification, average lead time
|
||||||
|
4. `calculate_brand_metrics.sql` — Brand-level aggregated metrics
|
||||||
|
5. `calculate_vendor_metrics.sql` — Vendor-level aggregated metrics
|
||||||
|
6. `calculate_category_metrics.sql` — Category-level metrics with hierarchy rollups
|
||||||
|
|
||||||
|
### Database Scale
|
||||||
|
| Table | Row Count |
|
||||||
|
|---|---|
|
||||||
|
| products | 681,912 |
|
||||||
|
| orders | 2,883,982 |
|
||||||
|
| purchase_orders | 256,809 |
|
||||||
|
| receivings | 313,036 |
|
||||||
|
| daily_product_snapshots | 678,312 (601 distinct dates, since 2024-06-01) |
|
||||||
|
| product_metrics | 681,912 |
|
||||||
|
| brand_metrics | 1,789 |
|
||||||
|
| vendor_metrics | 281 |
|
||||||
|
| category_metrics | 610 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
|
||||||
|
### ISSUE 1: [HIGH] Order status filter is non-functional — numeric codes vs text comparison
|
||||||
|
|
||||||
|
**Files:** `update_daily_snapshots.sql` lines 86-101, `update_product_metrics.sql` lines 89, 178-183
|
||||||
|
**Confirmed by data:** All order statuses are numeric strings ('100', '50', '55', etc.)
|
||||||
|
**Status mappings from:** `docs/prod_registry.class.php`
|
||||||
|
|
||||||
|
**Description:** The SQL filters `COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned')` and `o.status NOT IN ('canceled', 'returned')` are used throughout the pipeline to exclude canceled/returned orders. However, the import pipeline stores order statuses as their **raw numeric codes** from the production MySQL database (e.g., '100', '50', '55', '90', '92'). There are **zero text status values** in the orders table.
|
||||||
|
|
||||||
|
This means these filters **never exclude any rows** — every comparison is `'100' NOT IN ('canceled', 'returned')` which is always true.
|
||||||
|
|
||||||
|
**Actual status distribution (with confirmed meanings):**
|
||||||
|
| Status | Meaning | Count | Negative Qty | Assessment |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 100 | shipped | 2,862,792 | 3,352 | Completed — correct to include |
|
||||||
|
| 50 | awaiting_products | 11,109 | 0 | In-progress — not yet shipped |
|
||||||
|
| 55 | shipping_later | 5,689 | 0 | In-progress — not yet shipped |
|
||||||
|
| 56 | shipping_together | 2,863 | 0 | In-progress — not yet shipped |
|
||||||
|
| 90 | awaiting_shipment | 38 | 0 | Near-complete — not yet shipped |
|
||||||
|
| 92 | awaiting_pickup | 71 | 0 | Near-complete — awaiting customer |
|
||||||
|
| 95 | shipped_confirmed | 5 | 0 | Completed — correct to include |
|
||||||
|
| 15 | cancelled | 1 | 0 | Should be excluded |
|
||||||
|
|
||||||
|
**Full status reference (from prod_registry.class.php):**
|
||||||
|
- 0=created, 10=unfinished, **15=cancelled**, 16=combined, 20=placed, 22=placed_incomplete
|
||||||
|
- 30=cancelled_old (historical), 40=awaiting_payment, 50=awaiting_products
|
||||||
|
- 55=shipping_later, 56=shipping_together, 60=ready, 61=flagged
|
||||||
|
- 62=fix_before_pick, 65=manual_picking, 70=in_pt, 80=picked
|
||||||
|
- 90=awaiting_shipment, 91=remote_wait, **92=awaiting_pickup**, 93=fix_before_ship
|
||||||
|
- **95=shipped_confirmed**, **100=shipped**
|
||||||
|
|
||||||
|
**Severity revised to HIGH (from CRITICAL):** Now that we know the actual meanings, no cancelled/refunded orders are being miscounted (only 1 cancelled order exists, status=15). The real concern is twofold:
|
||||||
|
1. **The text-based filter is dead code** — it can never match any row. Either map statuses to text during import (like POs do) or change SQL to use numeric comparisons.
|
||||||
|
2. **~19,775 unfulfilled orders** (statuses 50/55/56/90/92) are counted as completed sales. These are orders in various stages of fulfillment that haven't shipped yet. While most will eventually ship, counting them now inflates current-period metrics. At 0.69% of total orders, the financial impact is modest but the filter should work correctly on principle.
|
||||||
|
|
||||||
|
**Note:** PO statuses ARE properly mapped to text ('canceled', 'done', etc.) in the import pipeline. Only order statuses are numeric.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 2: [CRITICAL] Daily Snapshots use current stock instead of historical EOD stock
|
||||||
|
|
||||||
|
**File:** `update_daily_snapshots.sql`, lines 126-135, 173
|
||||||
|
**Confirmed by data:** Top product (pid 666925) shows `eod_stock_quantity = 0` for ALL dates even though it sold 28 units on Jan 28 (clearly had stock then)
|
||||||
|
|
||||||
|
**Description:** The `CurrentStock` CTE reads `stock_quantity` directly from the `products` table at query execution time. When the script processes historical dates (today minus 1-4 days), it writes **today's stock** as if it were the end-of-day stock for those past dates.
|
||||||
|
|
||||||
|
**Cascading impact on product_metrics:**
|
||||||
|
- `avg_stock_units_30d` / `avg_stock_cost_30d` — Wrong averages
|
||||||
|
- `stockout_days_30d` — Undercounts (only based on current stock state, not historical)
|
||||||
|
- `stockout_rate_30d`, `service_level_30d`, `fill_rate_30d` — All derived from wrong stockout data
|
||||||
|
- `gmroi_30d` — Wrong denominator (avg stock cost)
|
||||||
|
- `stockturn_30d` — Wrong denominator (avg stock units)
|
||||||
|
- `sell_through_30d` — Affected by stock level inaccuracy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 3: [CRITICAL] Snapshot coverage is 0.17% — most products have no snapshot data
|
||||||
|
|
||||||
|
**Confirmed by data:** 678,312 snapshot rows across 601 dates = ~1,128 products/day out of 681,912 total
|
||||||
|
|
||||||
|
**Description:** The daily snapshots script only creates rows for products with sales or receiving activity on that date (`ProductsWithActivity` CTE, line 136). This means:
|
||||||
|
- 91.1% of products (621,221) have NULL `sales_30d` — they had no orders in the last 30 days so no snapshot rows exist
|
||||||
|
- `AVG(eod_stock_quantity)` averages only across days with activity, not 30 days
|
||||||
|
- `stockout_days_30d` only counts stockout days where there was ALSO some activity
|
||||||
|
- A product out of stock with zero sales gets zero stockout_days even though it was stocked out
|
||||||
|
|
||||||
|
This is by design (to avoid creating 681K rows/day) but means stock-related metrics are systematically biased.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 4: [HIGH] `costeach` fallback to 50% of price in import pipeline
|
||||||
|
|
||||||
|
**File:** `inventory-server/scripts/import/orders.js` (line ~573)
|
||||||
|
|
||||||
|
**Description:** When the MySQL `order_costs` table has no record for an order item, `costeach` defaults to `price * 0.5`. There is **no flag** in the PostgreSQL data to distinguish actual costs from estimated ones.
|
||||||
|
|
||||||
|
**Data impact:** 385,545 products (56.5%) have `current_cost_price = 0` AND `current_landing_cost_price = 0`. For these products, the COGS calculation in daily_snapshots falls through the chain:
|
||||||
|
1. `o.costeach` — May be the 50% estimate from import
|
||||||
|
2. `get_weighted_avg_cost()` — Returns NULL if no receivings exist
|
||||||
|
3. `p.landing_cost_price` — Always NULL (hardcoded in import)
|
||||||
|
4. `p.cost_price` — 0 for 56.5% of products
|
||||||
|
|
||||||
|
Only 27 products have zero COGS with positive sales, meaning the `costeach` field is doing its job for products that sell, but the 50% fallback means margins for those products are estimates, not actuals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 5: [HIGH] `landing_cost_price` is always NULL
|
||||||
|
|
||||||
|
**File:** `inventory-server/scripts/import/products.js` (line ~175)
|
||||||
|
|
||||||
|
**Description:** The import explicitly sets `landing_cost_price = NULL` for all products. The daily_snapshots COGS calculation uses it as a fallback: `COALESCE(o.costeach, get_weighted_avg_cost(...), p.landing_cost_price, p.cost_price)`. Since it's always NULL, this fallback step is useless and the chain jumps straight to `cost_price`.
|
||||||
|
|
||||||
|
The `product_metrics` field `current_landing_cost_price` is populated as `COALESCE(p.landing_cost_price, p.cost_price, 0.00)`, so it equals `cost_price` for all products. Any UI showing "landing cost" is actually just showing `cost_price`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 6: [HIGH] Vendor lead time is drastically wrong — missing supplier_id join
|
||||||
|
|
||||||
|
**File:** `calculate_vendor_metrics.sql`, lines 62-82
|
||||||
|
**Confirmed by data:** Vendor-level lead times are 2-10x higher than product-level lead times
|
||||||
|
|
||||||
|
**Description:** The vendor metrics lead time joins POs to receivings only by `pid`:
|
||||||
|
```sql
|
||||||
|
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||||
|
```
|
||||||
|
But the periodic metrics lead time correctly matches supplier:
|
||||||
|
```sql
|
||||||
|
JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Without supplier matching, a PO for product X from Vendor A can match a receiving of product X from Vendor B, creating inflated/wrong lead times.
|
||||||
|
|
||||||
|
**Measured discrepancies:**
|
||||||
|
| Vendor | Vendor Metrics Lead Time | Avg Product Lead Time |
|
||||||
|
|---|---|---|
|
||||||
|
| doodlebug design inc. | 66 days | 14 days |
|
||||||
|
| Notions | 55 days | 4 days |
|
||||||
|
| Simple Stories | 59 days | 27 days |
|
||||||
|
| Ranger Industries | 31 days | 5 days |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 7: [MEDIUM] Net revenue does not subtract returns
|
||||||
|
|
||||||
|
**File:** `update_daily_snapshots.sql`, line 184
|
||||||
|
|
||||||
|
**Description:** `net_revenue = gross_revenue - discounts`. Standard accounting: `net_revenue = gross_revenue - discounts - returns`. The `returns_revenue` is calculated separately but not deducted.
|
||||||
|
|
||||||
|
**Data impact:** There are 3,352 orders with negative quantities (returns), totaling -5,499 units. These returns are tracked in `returns_revenue` but not reflected in `net_revenue`, which means all downstream revenue-based metrics are slightly overstated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 8: [MEDIUM] Lifetime revenue subquery references wrong table columns
|
||||||
|
|
||||||
|
**File:** `update_product_metrics.sql`, lines 323-329
|
||||||
|
|
||||||
|
**Description:** The lifetime revenue estimation fallback queries:
|
||||||
|
```sql
|
||||||
|
SELECT revenue_7d / NULLIF(sales_7d, 0)
|
||||||
|
FROM daily_product_snapshots
|
||||||
|
WHERE pid = ci.pid AND sales_7d > 0
|
||||||
|
```
|
||||||
|
But `daily_product_snapshots` does NOT have `revenue_7d` or `sales_7d` columns — those exist in `product_metrics`. This subquery either errors silently or returns NULL. The effect is that the estimation always falls back to `current_price * total_sold`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 9: [MEDIUM] Brand/Vendor metrics COGS filter inflates margins
|
||||||
|
|
||||||
|
**Files:** `calculate_brand_metrics.sql` lines 31, `calculate_vendor_metrics.sql` line 32
|
||||||
|
|
||||||
|
**Description:** `SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END)` excludes products with zero COGS. But if a product has sales revenue and zero COGS (missing cost data), the brand/vendor totals will include the revenue but not the COGS, artificially inflating the margin.
|
||||||
|
|
||||||
|
**Data context:** Brand metrics revenue matches product_metrics aggregation exactly for sales counts, but shows small discrepancies in revenue (e.g., Stamperia: $7,613.98 brand vs $7,611.11 actual). These tiny diffs come from the `> 0` filtering excluding products with negative revenue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 10: [MEDIUM] Extreme margin values from $0.01 price orders
|
||||||
|
|
||||||
|
**Confirmed by data:** 73 products with margin > 100%, 119 with margin < -100%
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
| Product | Revenue | COGS | Margin |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Flower Gift Box Die (pid 624756) | $0.02 | $29.98 | -149,800% |
|
||||||
|
| Special Flowers Stamp Set (pid 614513) | $0.01 | $11.97 | -119,632% |
|
||||||
|
|
||||||
|
These are products with extremely low prices (likely samples, promos, or data errors) where the order price was $0.01. The margin calculation is mathematically correct but these outliers skew any aggregate margin statistics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 11: [MEDIUM] Sell-through rate has edge cases yielding negative/extreme values
|
||||||
|
|
||||||
|
**File:** `update_product_metrics.sql`, lines 358-361
|
||||||
|
**Confirmed by data:** 30 products with negative sell-through, 10 with sell-through > 200%
|
||||||
|
|
||||||
|
**Description:** Beginning inventory is approximated as `current_stock + sales - received + returns`. When inventory adjustments, shrinkage, or manual corrections occur, this approximation breaks. Edge cases:
|
||||||
|
- Products with many manual stock adjustments → negative denominator → negative sell-through
|
||||||
|
- Products with beginning stock near zero but decent sales → sell-through > 100%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 12: [MEDIUM] `total_sold` uses different status filter than orders import
|
||||||
|
|
||||||
|
**Import pipeline confirmed:**
|
||||||
|
- Orders import: `order_status >= 15` (includes processing/pending orders)
|
||||||
|
- `total_sold` in products: `order_status >= 20` (more restrictive)
|
||||||
|
|
||||||
|
This means `lifetime_sales` (from `total_sold`) is systematically lower than what you'd calculate by summing the orders table. The discrepancy is confirmed:
|
||||||
|
| Product | total_sold | orders sum | Gap |
|
||||||
|
|---|---|---|---|
|
||||||
|
| pid 31286 | 13,786 | 4,241 | 9,545 |
|
||||||
|
| pid 44309 | 11,978 | 3,119 | 8,859 |
|
||||||
|
|
||||||
|
The large gaps are because the orders table only has data from the import start date (~2024), while `total_sold` includes all-time sales from MySQL. This is expected behavior, not a bug, but it means the `lifetime_revenue_quality` flag is important — most products show 'estimated' quality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 13: [MEDIUM] Category rollup may double-count products in multiple hierarchy levels
|
||||||
|
|
||||||
|
**File:** `calculate_category_metrics.sql`, lines 42-66
|
||||||
|
|
||||||
|
**Description:** The `RolledUpMetrics` CTE uses:
|
||||||
|
```sql
|
||||||
|
dcm.cat_id = ch.cat_id OR dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
||||||
|
```
|
||||||
|
If products are assigned to categories at multiple levels in the same branch (e.g., both "Paper Crafts" and "Scrapbook Paper" which is a child of "Paper Crafts"), those products' metrics would be counted twice in the parent's rollup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 14: [LOW] `exclude_forecast` removes products from metrics entirely
|
||||||
|
|
||||||
|
**File:** `update_product_metrics.sql`, line 509
|
||||||
|
|
||||||
|
**Description:** `WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL` is on the main INSERT's WHERE clause. Products with `exclude_forecast = TRUE` won't appear in `product_metrics` at all, rather than just having forecast fields nulled. Currently all 681,912 products are in product_metrics so this appears to not affect any products yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 15: [LOW] Daily snapshots only look back 5 days
|
||||||
|
|
||||||
|
**File:** `update_daily_snapshots.sql`, line 14 — `_process_days INT := 5`
|
||||||
|
|
||||||
|
If import data arrives late (>5 days), those days will never get snapshots populated. There is a separate `backfill/rebuild_daily_snapshots.sql` for historical rebuilds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ISSUE 16: [INFO] Timezone risk in order date import
|
||||||
|
|
||||||
|
**File:** `inventory-server/scripts/import/orders.js`
|
||||||
|
|
||||||
|
MySQL `DATETIME` values are timezone-naive. The import uses `new Date(order.date)` which interprets them using the import server's local timezone. The SSH config specifies `timezone: '-05:00'` for MySQL (always EST). If the import server is in a different timezone, orders near midnight could land on the wrong date in the daily snapshots calculation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Functions Review
|
||||||
|
|
||||||
|
### `calculate_sales_velocity(sales_30d, stockout_days_30d)`
|
||||||
|
- Divides `sales_30d` by effective selling days: `GREATEST(30 - stockout_days, CASE WHEN sales > 0 THEN 14 ELSE 30 END)`
|
||||||
|
- The 14-day floor prevents extreme velocity for products mostly out of stock
|
||||||
|
- **Sound approach** — the only concern is that stockout_days is unreliable (Issues 2, 3)
|
||||||
|
|
||||||
|
### `get_weighted_avg_cost(pid, date)`
|
||||||
|
- Weighted average of last 10 receivings by cost*qty/qty
|
||||||
|
- Returns NULL if no receivings — sound fallback behavior
|
||||||
|
- **Correct implementation**
|
||||||
|
|
||||||
|
### `safe_divide(numerator, denominator)`
|
||||||
|
- Returns NULL on divide-by-zero — **correct**
|
||||||
|
|
||||||
|
### `std_numeric(value, precision)`
|
||||||
|
- Rounds to precision digits — **correct**
|
||||||
|
|
||||||
|
### `classify_demand_pattern(avg_demand, cv)`
|
||||||
|
- Uses coefficient of variation thresholds: ≤0.2 = stable, ≤0.5 = variable, low-volume+high-CV = sporadic, else lumpy
|
||||||
|
- **Reasonable classification**, though only based on 30-day window
|
||||||
|
|
||||||
|
### `detect_seasonal_pattern(pid)`
|
||||||
|
- CROSS JOIN LATERAL (runs per product) — **expensive**: queries `daily_product_snapshots` twice per product
|
||||||
|
- Compares current month average to yearly average — very simplistic
|
||||||
|
- **Functional but could be a performance bottleneck** with 681K products
|
||||||
|
|
||||||
|
### `category_hierarchy` (materialized view)
|
||||||
|
- Recursive CTE building tree from categories — **correct implementation**
|
||||||
|
- Refreshed concurrently before category metrics calculation — **good practice**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Health Summary
|
||||||
|
|
||||||
|
| Metric | Count | % of Total |
|
||||||
|
|---|---|---|
|
||||||
|
| Products with zero cost_price | 385,545 | 56.5% |
|
||||||
|
| Products with NULL sales_30d | 621,221 | 91.1% |
|
||||||
|
| Products with no lifetime_sales | 321,321 | 47.1% |
|
||||||
|
| Products with zero COGS but positive sales | 27 | <0.01% |
|
||||||
|
| Products with margin > 100% | 73 | <0.01% |
|
||||||
|
| Products with margin < -100% | 119 | <0.01% |
|
||||||
|
| Products with negative sell-through | 30 | <0.01% |
|
||||||
|
| Products with NULL status | 0 | 0% |
|
||||||
|
| Duplicate daily snapshots (same pid+date) | 0 | 0% |
|
||||||
|
| Net revenue formula mismatches | 0 | 0% |
|
||||||
|
|
||||||
|
### ABC Classification Distribution (replenishable products only)
|
||||||
|
| Class | Products | Revenue % |
|
||||||
|
|---|---|---|
|
||||||
|
| A | 7,727 | 80.72% |
|
||||||
|
| B | 12,048 | 15.10% |
|
||||||
|
| C | 113,647 | 4.18% |
|
||||||
|
|
||||||
|
ABC distribution looks healthy — A ≈ 80%, A+B ≈ 96%.
|
||||||
|
|
||||||
|
### Brand Metrics Consistency
|
||||||
|
Product counts and sales_30d match exactly between `brand_metrics` and direct aggregation from `product_metrics`. Revenue shows sub-dollar discrepancies due to the `> 0` filter excluding products with negative revenue. **Consistent within expected tolerance.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Recommendations
|
||||||
|
|
||||||
|
### Must Fix (Correctness Issues)
|
||||||
|
1. **Issue 1: Fix order status handling** — The text-based filter (`NOT IN ('canceled', 'returned')`) is dead code against numeric statuses. Two options: (a) map numeric statuses to text during import (like POs already do), or (b) change SQL to filter on numeric codes (e.g., `o.status::int >= 20` to exclude cancelled/unfinished, or `o.status IN ('100', '95')` for shipped-only). The ~19.7K unfulfilled orders (0.69%) are a minor financial impact but the filter should be functional.
|
||||||
|
2. **Issue 6: Add supplier_id join to vendor lead time** — One-line fix in `calculate_vendor_metrics.sql`
|
||||||
|
3. **Issue 8: Fix lifetime revenue subquery** — Use correct column names from `daily_product_snapshots` (e.g., `net_revenue / NULLIF(units_sold, 0)`)
|
||||||
|
|
||||||
|
### Should Fix (Data Quality)
|
||||||
|
4. **Issue 2/3: Snapshot coverage** — Consider creating snapshot rows for all in-stock products, not just those with activity. Or at minimum, calculate stockout metrics by comparing snapshot existence to product existence.
|
||||||
|
5. **Issue 5: Populate landing_cost_price** — If available in the source system, import it. Otherwise remove references to avoid confusion.
|
||||||
|
6. **Issue 7: Subtract returns from net_revenue** — `net_revenue = gross_revenue - discounts - returns_revenue`
|
||||||
|
7. **Issue 9: Remove > 0 filter on COGS** — Use `SUM(pm.cogs_30d)` instead of conditional sums
|
||||||
|
|
||||||
|
### Nice to Fix (Edge Cases)
|
||||||
|
8. **Issue 4: Flag estimated costs** — Add a `costeach_estimated BOOLEAN` to orders during import
|
||||||
|
9. **Issue 10: Cap or flag extreme margins** — Exclude $0.01-price orders from margin calculations
|
||||||
|
10. **Issue 11: Clamp sell-through** — `GREATEST(0, LEAST(sell_through_30d, 200))` or flag outliers
|
||||||
|
11. **Issue 12: Verify category assignment policy** — Check if products are assigned to leaf categories only
|
||||||
|
12. **Issue 13: Category rollup query** — Verify no double-counting with actual data
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# Metrics Pipeline Audit Report
|
||||||
|
|
||||||
|
**Date:** 2026-02-08
|
||||||
|
**Scope:** All 6 SQL scripts in `inventory-server/scripts/metrics-new/`, import pipeline, custom functions, and post-calculation data verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The metrics pipeline is architecturally sound and the core calculations are mostly correct. The 30-day sales, revenue, replenishment, and aggregate metrics (brand/vendor/category) all cross-check accurately between the snapshots, product_metrics, and direct orders queries. However, several issues were found ranging from **critical data bugs** to **design limitations** that affect accuracy of specific metrics.
|
||||||
|
|
||||||
|
**Issues found: 13** (3 Critical, 4 Medium, 6 Low/Informational)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL Issues
|
||||||
|
|
||||||
|
### C1. `net_revenue` in daily snapshots never subtracts returns ($35.6K affected)
|
||||||
|
|
||||||
|
**Location:** `update_daily_snapshots.sql`, line 181
|
||||||
|
**Symptom:** `net_revenue` is stored as `gross_revenue - discounts` but should be `gross_revenue - discounts - returns_revenue`.
|
||||||
|
|
||||||
|
The SQL formula on line 181 appears correct:
|
||||||
|
```sql
|
||||||
|
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue
|
||||||
|
```
|
||||||
|
|
||||||
|
However, actual data shows `net_revenue = gross_revenue - discounts` for ALL 3,252 snapshots that have returns. Total returns not subtracted: **$35,630.03** across 2,946 products. This may be caused by the `returns_revenue` in the SalesData CTE not properly flowing through to the INSERT, or by a prior version of the code that stored these values differently. The profit column (line 184) has the same issue: `(gross - discounts) - cogs` instead of `(gross - discounts - returns) - cogs`.
|
||||||
|
|
||||||
|
**Impact:** Net revenue and profit are overstated by the amount of returns. This cascades to all metrics derived from snapshots: `revenue_30d`, `profit_30d`, `margin_30d`, `avg_ros_30d`, and all brand/vendor/category aggregate revenue.
|
||||||
|
|
||||||
|
**Recommended fix:** Debug why the returns subtraction isn't taking effect. The formula in the SQL looks correct, so this may be a data-type issue or an execution path issue. After fixing, rebuild snapshots.
|
||||||
|
|
||||||
|
**Status:** Owner will resolve. Code formula is correct; snapshots need rebuilding after prior fix deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2. `eod_stock_quantity` uses CURRENT stock, not historical end-of-day stock
|
||||||
|
|
||||||
|
**Location:** `update_daily_snapshots.sql`, lines 123-132 (CurrentStock CTE)
|
||||||
|
**Symptom:** Every snapshot for a given product shows the same stock quantity regardless of the snapshot date.
|
||||||
|
|
||||||
|
The `CurrentStock` CTE simply reads `stock_quantity` from the `products` table:
|
||||||
|
```sql
|
||||||
|
SELECT pid, stock_quantity, ... FROM public.products
|
||||||
|
```
|
||||||
|
|
||||||
|
This means a snapshot from January 10 shows the SAME stock as today (February 8). Verified in data:
|
||||||
|
- Product 662561: stock = 36 on every date (Feb 1-7)
|
||||||
|
- Product 665397: stock = 25 on every date (Feb 1-7)
|
||||||
|
- All products checked show identical stock across all snapshot dates
|
||||||
|
|
||||||
|
**Impact:** All stock-derived metrics are inaccurate for historical analysis:
|
||||||
|
- `eod_stock_cost`, `eod_stock_retail`, `eod_stock_gross` (all wrong for past dates)
|
||||||
|
- `stockout_flag` (based on current stock, not historical)
|
||||||
|
- `stockout_days_30d` (undercounted since stockout_flag uses current stock)
|
||||||
|
- `avg_stock_units_30d`, `avg_stock_cost_30d` (no variance, just current stock repeated)
|
||||||
|
- `gmroi_30d`, `stockturn_30d` (based on avg_stock which is flat)
|
||||||
|
- `sell_through_30d` (denominator uses current stock assumption)
|
||||||
|
- `service_level_30d`, `fill_rate_30d`
|
||||||
|
|
||||||
|
**This is a known architectural limitation** noted in MEMORY.md. Fixing requires either:
|
||||||
|
1. Storing stock snapshots separately at end-of-day (ideally via a cron job that records stock before any changes)
|
||||||
|
2. Reconstructing historical stock from orders and receivings (complex but possible)
|
||||||
|
|
||||||
|
**Status: FIXED.** MySQL's `snap_product_value` table (daily EOD stock per product since 2012) is now imported into PostgreSQL `stock_snapshots` table via `scripts/import/stock-snapshots.js`. The `CurrentStock` CTE in `update_daily_snapshots.sql` now uses `LEFT JOIN stock_snapshots` for historical stock, falling back to `products.stock_quantity` when no historical data exists. Requires: run import, then rebuild daily snapshots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C3. `ON CONFLICT DO UPDATE WHERE` check skips 91%+ of product_metrics updates
|
||||||
|
|
||||||
|
**Location:** `update_product_metrics.sql`, lines 558-574
|
||||||
|
**Symptom:** 623,205 of 681,912 products (91.4%) have `last_calculated` older than 1 day. 592,369 are over 30 days old. 914 products with active 30-day sales haven't been updated in over 7 days.
|
||||||
|
|
||||||
|
The upsert's `WHERE` clause only updates if specific fields changed:
|
||||||
|
```sql
|
||||||
|
WHERE product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
|
||||||
|
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields NOT checked include: `stockout_days_30d`, `margin_30d`, `gmroi_30d`, `demand_pattern`, `seasonality_index`, `sales_growth_*`, `service_level_30d`, and many others. If a product's stock, price, sales, and revenue haven't changed, the entire row is skipped even though growth metrics, variability, and other derived fields may need updating.
|
||||||
|
|
||||||
|
**Impact:** Most derived metrics (growth, demand patterns, seasonality) are stale for the majority of products. Products with steady sales but unchanged stock/price never get their growth metrics recalculated.
|
||||||
|
|
||||||
|
**Recommended fix:** Either:
|
||||||
|
1. Remove the `WHERE` clause entirely (accept the performance cost of writing all rows every run)
|
||||||
|
2. Add `last_calculated` age check: `OR product_metrics.last_calculated < NOW() - INTERVAL '7 days'`
|
||||||
|
3. Add the missing fields to the change-detection check
|
||||||
|
|
||||||
|
**Status: FIXED.** Added 12 derived fields to the `IS DISTINCT FROM` check (`profit_30d`, `cogs_30d`, `margin_30d`, `stockout_days_30d`, `sell_through_30d`, `sales_growth_30d_vs_prev`, `revenue_growth_30d_vs_prev`, `demand_pattern`, `seasonal_pattern`, `seasonality_index`, `service_level_30d`, `fill_rate_30d`) plus a time-based safety net: `OR product_metrics.last_calculated < NOW() - INTERVAL '1 day'`. This guarantees every row is refreshed at least daily.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM Issues
|
||||||
|
|
||||||
|
### M1. Demand variability calculated only over activity days, not full 30-day window
|
||||||
|
|
||||||
|
**Location:** `update_product_metrics.sql`, DemandVariability CTE (lines 206-223)
|
||||||
|
**Symptom:** Variance, std_dev, and CV are computed over only the days that appear in snapshots (activity days), not the full 30-day period including zero-sales days.
|
||||||
|
|
||||||
|
Example: Product 41141 (Mexican Poppy) sold 102 units in 30 days across only 3 snapshot days (1, 1, 100). The variance/CV is calculated over just those 3 data points instead of 30 (with 27 zero-sales days).
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- CV is computed on sparse data (3-10 points instead of 30), making it statistically unreliable
|
||||||
|
- Products with sporadic large orders appear less variable than they really are
|
||||||
|
- `demand_pattern` classification is affected (stable/variable/sporadic/lumpy)
|
||||||
|
|
||||||
|
**Recommended fix:** Join against a generated 30-day date series and COALESCE missing days to 0 units sold before computing variance/stddev/CV.
|
||||||
|
|
||||||
|
**Status: FIXED.** Rewrote `DemandVariability` CTE to use `generate_series()` for the full 30-day date range, `CROSS JOIN` with distinct PIDs from snapshots, and `LEFT JOIN` actual snapshot data with `COALESCE(dps.units_sold, 0)` for missing days. Variance/stddev/CV now computed over all 30 data points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M2. `costeach` fallback to `price * 0.5` affects 32.5% of recent orders
|
||||||
|
|
||||||
|
**Location:** `orders.js`, line 600 and 634
|
||||||
|
**Symptom:** When no cost record exists in `order_costs`, the import falls back to `price * 0.5`.
|
||||||
|
|
||||||
|
Data shows 9,839 of 30,266 recent orders (32.5%) use this fallback. Among these, 79 paid products have `costeach = 0` because `price = 0 * 0.5 = 0`, even though the product has a real cost_price.
|
||||||
|
|
||||||
|
The daily snapshot has a second line of defense (using `get_weighted_avg_cost()` and then `p.cost_price`), but the orders table's `costeach` column itself contains inaccurate data for ~1/3 of orders.
|
||||||
|
|
||||||
|
**Impact:** COGS calculations at the order level are approximate for 1/3 of orders. The snapshot's fallback chain mitigates this somewhat, but any analytics using `orders.costeach` directly will be affected.
|
||||||
|
|
||||||
|
**Status: FIXED.** Added `products.cost_price` as intermediate fallback: `COALESCE(oc.costeach, p.cost_price, oi.price * 0.5)`. The products table join was added to both the `order_totals` CTE and the outer SELECT in `orders.js`. Requires a full orders re-import to apply retroactively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M3. `lifetime_sales` uses MySQL `total_sold` (status >= 20) but orders import uses status >= 15
|
||||||
|
|
||||||
|
**Location:** `products.js` line 200 vs `orders.js` line 69
|
||||||
|
**Symptom:** `total_sold` in the products table comes from MySQL with `order_status >= 20`, excluding status 15 (canceled) and 16 (combined). But the orders import fetches orders with `order_status >= 15`.
|
||||||
|
|
||||||
|
Verified in MySQL: For product 31286, `total_sold` (>=20) = 13,786 vs (>=15) = 13,905 (difference of 119 units).
|
||||||
|
|
||||||
|
**Impact:** `lifetime_sales` in product_metrics (sourced from `products.total_sold`) slightly understates compared to what the orders table contains. The `lifetime_revenue_quality` field correctly flags most as "estimated" since the orders table only covers ~5 years while `total_sold` is all-time. This is a minor inconsistency (< 1% difference).
|
||||||
|
|
||||||
|
**Status:** Accepted. < 1% difference, not worth the complexity of aligning thresholds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4. `sell_through_30d` has 868 NULL values and 547 anomalous values for products with sales
|
||||||
|
|
||||||
|
**Location:** `update_product_metrics.sql`, lines 356-361
|
||||||
|
**Formula:** `(sales_30d / (current_stock + sales_30d + returns_units_30d - received_qty_30d)) * 100`
|
||||||
|
|
||||||
|
- 868 products with sales but NULL sell_through (denominator = 0, which happens when `current_stock + sales - received = 0`, i.e. all stock came from receiving and was sold)
|
||||||
|
- 259 products with sell_through > 100%
|
||||||
|
- 288 products with negative sell_through
|
||||||
|
|
||||||
|
**Impact:** Sell-through rate is unreliable for products with significant receiving activity in the same period. The formula tries to approximate "beginning inventory" but the approximation breaks when current stock ≠ actual beginning stock (which is always, per issue C2).
|
||||||
|
|
||||||
|
**Status:** Will improve once C2 fix (historical stock) is deployed and snapshots are rebuilt, since `current_stock` in the formula will then reflect actual beginning inventory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW / INFORMATIONAL Issues
|
||||||
|
|
||||||
|
### L1. Snapshots only cover ~1,167 products/day out of 681K
|
||||||
|
|
||||||
|
Only products with order or receiving activity on a given day get snapshots. This is by design (the `ProductsWithActivity` CTE on line 133 of `update_daily_snapshots.sql`), but it means:
|
||||||
|
- 560K+ products have zero snapshot history
|
||||||
|
- Stockout tracking is impossible for products with no sales (they can't appear in snapshots)
|
||||||
|
- The "avg_stock" metrics (avg_stock_units_30d, etc.) only average over activity days, not all 30 days
|
||||||
|
|
||||||
|
This is acceptable for storage efficiency but should be understood when interpreting metrics.
|
||||||
|
|
||||||
|
**Status:** Accepted (by design).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L2. `detect_seasonal_pattern` function only compares current month to yearly average
|
||||||
|
|
||||||
|
The seasonality detection is simplistic: it compares current month's avg daily sales to yearly avg. This means:
|
||||||
|
- It can only detect if the CURRENT month is above average, not identify historical seasonal patterns
|
||||||
|
- Running in January vs July will give completely different results for the same product
|
||||||
|
- The "peak_season" field always shows the current month/quarter when seasonal (not the actual peak)
|
||||||
|
|
||||||
|
This is noted as a P5 (low priority) feature and is adequate for a first pass but should not be relied upon for demand planning.
|
||||||
|
|
||||||
|
**Status: FIXED.** Rewrote `detect_seasonal_pattern` function to compare monthly average sales across the full last 12 months. Uses CV across months + peak-to-average ratio for classification: `strong` (CV > 0.5, peak > 150%), `moderate` (CV > 0.3, peak > 120%), `none`. Peak season now identifies the actual highest-sales month. Requires at least 3 months of data. Saved in `db/functions.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L3. Free product with negative revenue in top sellers
|
||||||
|
|
||||||
|
Product 476848 ("Thank You, From ACOT!") shows 254 sales with -$1.00 revenue because one order applied a $1 discount to a $0 product. This is a data oddity, not a calculation bug. Could be addressed by excluding $0-price products from revenue metrics or by data cleanup.
|
||||||
|
|
||||||
|
**Status:** Accepted (data oddity, not a bug).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L4. `landing_cost_price` is always NULL
|
||||||
|
|
||||||
|
`current_landing_cost_price` in product_metrics is mapped from `current_effective_cost` which is just `cost_price`. The `landing_cost_price` concept (cost + shipping + duties) is not implemented. The field exists but has no meaningful data.
|
||||||
|
|
||||||
|
**Status: FIXED.** Removed `landing_cost_price` from `db/schema.sql`, `current_landing_cost_price` from `db/metrics-schema-new.sql`, `update_product_metrics.sql`, and `backfill/populate_initial_product_metrics.sql`. Column should be dropped from the live database via `ALTER TABLE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L5. Custom SQL functions not tracked in version control
|
||||||
|
|
||||||
|
All 6 custom functions (`calculate_sales_velocity`, `get_weighted_avg_cost`, `safe_divide`, `std_numeric`, `classify_demand_pattern`, `detect_seasonal_pattern`) and the `category_hierarchy` materialized view exist only in the database. They are not defined in any migration or schema file in the repository.
|
||||||
|
|
||||||
|
If the database needs to be recreated, these would be lost.
|
||||||
|
|
||||||
|
**Status: FIXED.** All 6 functions and the `category_hierarchy` materialized view definition saved to `inventory-server/db/functions.sql`. File is re-runnable via `psql -f functions.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### L6. `get_weighted_avg_cost` limited to last 10 receivings
|
||||||
|
|
||||||
|
The function `LIMIT 10` for performance, but this means products with many small receivings may not accurately reflect the true weighted average cost if the cost has changed significantly beyond the last 10 receiving records.
|
||||||
|
|
||||||
|
**Status: FIXED.** Removed `LIMIT 10` from `get_weighted_avg_cost`. Data shows max receivings per product is 142 (p95 = 11, avg = 3), so performance impact is negligible. Updated definition in `db/functions.sql`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Summary
|
||||||
|
|
||||||
|
### What's Working Correctly
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|-------|--------|
|
||||||
|
| 30d sales: product_metrics vs orders vs snapshots | **MATCH** (verified top 10 sellers) |
|
||||||
|
| Replenishment formula: manual calc vs stored | **MATCH** (verified 10 products) |
|
||||||
|
| Brand metrics vs sum of product_metrics | **MATCH** (0 difference across all brands) |
|
||||||
|
| Order status mapping (numeric → text) | **CORRECT** (all statuses mapped, no numeric remain) |
|
||||||
|
| Cost price: PostgreSQL vs MySQL source | **MATCH** (within rounding, verified 5 products) |
|
||||||
|
| total_sold: PostgreSQL vs MySQL source | **MATCH** (verified 5 products) |
|
||||||
|
| Category rollups (rolled-up > direct for parents) | **CORRECT** |
|
||||||
|
| ABC classification distribution | **REASONABLE** (A: 8K, B: 12.5K, C: 113K) |
|
||||||
|
| Lead time calculation (PO → receiving) | **CORRECT** (verified examples) |
|
||||||
|
|
||||||
|
### Data Overview
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total products | 681,912 |
|
||||||
|
| Products in product_metrics | 681,912 (100%) |
|
||||||
|
| Products with 30d sales | 10,291 (1.5%) |
|
||||||
|
| Products with negative profit & revenue | 139 (mostly cost > price) |
|
||||||
|
| Products with negative stock | 0 |
|
||||||
|
| Snapshot date range | 2020-06-18 to 2026-02-08 |
|
||||||
|
| Avg products per snapshot day | 1,167 |
|
||||||
|
| Order date range | 2020-06-18 to 2026-02-08 |
|
||||||
|
| Total orders | 2,885,825 |
|
||||||
|
| 'returned' status orders | 0 (returns via negative quantity only) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Status Summary
|
||||||
|
|
||||||
|
| Issue | Severity | Status | Deployment Action Needed |
|
||||||
|
|-------|----------|--------|--------------------------|
|
||||||
|
| C1 | Critical | Owner resolving | Rebuild daily snapshots |
|
||||||
|
| C2 | Critical | **FIXED** | Run import, rebuild daily snapshots |
|
||||||
|
| C3 | Critical | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||||
|
| M1 | Medium | **FIXED** | Deploy updated `update_product_metrics.sql` |
|
||||||
|
| M2 | Medium | **FIXED** | Full orders re-import (`--full`) |
|
||||||
|
| M3 | Medium | Accepted | None |
|
||||||
|
| M4 | Medium | Pending C2 | Will improve after C2 deployment |
|
||||||
|
| L1 | Low | Accepted | None |
|
||||||
|
| L2 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||||
|
| L3 | Low | Accepted | None |
|
||||||
|
| L4 | Low | **FIXED** | `ALTER TABLE` to drop columns |
|
||||||
|
| L5 | Low | **FIXED** | None (file committed) |
|
||||||
|
| L6 | Low | **FIXED** | Deploy `db/functions.sql` to database |
|
||||||
|
|
||||||
|
### Deployment Steps
|
||||||
|
|
||||||
|
1. Deploy `db/functions.sql` to PostgreSQL: `psql -d inventory_db -f db/functions.sql` (L2, L6)
|
||||||
|
2. Run import (includes stock snapshots first load) (C2, M2)
|
||||||
|
3. Drop stale columns: `ALTER TABLE products DROP COLUMN IF EXISTS landing_cost_price; ALTER TABLE product_metrics DROP COLUMN IF EXISTS current_landing_cost_price;` (L4)
|
||||||
|
4. Rebuild daily snapshots (C1, C2)
|
||||||
|
5. Re-run metrics calculation (C3, M1 take effect automatically)
|
||||||
+60
-28
@@ -7,12 +7,13 @@ This document outlines the permission system implemented in the Inventory Manage
|
|||||||
Permissions follow this naming convention:
|
Permissions follow this naming convention:
|
||||||
|
|
||||||
- Page access: `access:{page_name}`
|
- Page access: `access:{page_name}`
|
||||||
- Actions: `{action}:{resource}`
|
- Settings sections: `settings:{section_name}`
|
||||||
|
- Admin features: `admin:{feature}`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `access:products` - Can access the Products page
|
- `access:products` - Can access the Products page
|
||||||
- `create:products` - Can create new products
|
- `settings:user_management` - Can access User Management settings
|
||||||
- `edit:users` - Can edit user accounts
|
- `admin:debug` - Can see debug information
|
||||||
|
|
||||||
## Permission Components
|
## Permission Components
|
||||||
|
|
||||||
@@ -22,10 +23,10 @@ The core component that conditionally renders content based on permissions.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<PermissionGuard
|
<PermissionGuard
|
||||||
permission="create:products"
|
permission="settings:user_management"
|
||||||
fallback={<p>No permission</p>}
|
fallback={<p>No permission</p>}
|
||||||
>
|
>
|
||||||
<button>Create Product</button>
|
<button>Manage Users</button>
|
||||||
</PermissionGuard>
|
</PermissionGuard>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ Specific component for settings with built-in permission checks.
|
|||||||
<SettingsSection
|
<SettingsSection
|
||||||
title="System Settings"
|
title="System Settings"
|
||||||
description="Configure global settings"
|
description="Configure global settings"
|
||||||
permission="edit:system_settings"
|
permission="settings:global"
|
||||||
>
|
>
|
||||||
{/* Settings content */}
|
{/* Settings content */}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -95,8 +96,8 @@ Core hook for checking any permission.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||||
if (hasPermission('delete:products')) {
|
if (hasPermission('settings:user_management')) {
|
||||||
// Can delete products
|
// Can access user management
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,8 +107,8 @@ Specialized hook for page-level permissions.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||||
if (canEdit()) {
|
if (canView()) {
|
||||||
// Can edit products
|
// Can view products
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,18 +120,43 @@ Permissions are stored in the database:
|
|||||||
|
|
||||||
Admin users automatically have all permissions.
|
Admin users automatically have all permissions.
|
||||||
|
|
||||||
## Common Permission Codes
|
## Implemented Permission Codes
|
||||||
|
|
||||||
|
### Page Access Permissions
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `access:dashboard` | Access to Dashboard page |
|
| `access:dashboard` | Access to Dashboard page |
|
||||||
|
| `access:overview` | Access to Overview page |
|
||||||
| `access:products` | Access to Products page |
|
| `access:products` | Access to Products page |
|
||||||
| `create:products` | Create new products |
|
| `access:categories` | Access to Categories page |
|
||||||
| `edit:products` | Edit existing products |
|
| `access:brands` | Access to Brands page |
|
||||||
| `delete:products` | Delete products |
|
| `access:vendors` | Access to Vendors page |
|
||||||
| `view:users` | View user accounts |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `edit:users` | Edit user accounts |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `manage:permissions` | Assign permissions to users |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
|
| `access:import` | Access to Import page |
|
||||||
|
| `access:settings` | Access to Settings page |
|
||||||
|
| `access:chat` | Access to Chat Archive page |
|
||||||
|
|
||||||
|
### Settings Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `settings:global` | Access to Global Settings section |
|
||||||
|
| `settings:products` | Access to Product Settings section |
|
||||||
|
| `settings:vendors` | Access to Vendor Settings section |
|
||||||
|
| `settings:data_management` | Access to Data Management settings |
|
||||||
|
| `settings:calculation_settings` | Access to Calculation Settings |
|
||||||
|
| `settings:library_management` | Access to Image Library Management |
|
||||||
|
| `settings:performance_metrics` | Access to Performance Metrics |
|
||||||
|
| `settings:prompt_management` | Access to AI Prompt Management |
|
||||||
|
| `settings:stock_management` | Access to Stock Management |
|
||||||
|
| `settings:templates` | Access to Template Management |
|
||||||
|
| `settings:user_management` | Access to User Management |
|
||||||
|
|
||||||
|
### Admin Permissions
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `admin:debug` | Can see debug information and features |
|
||||||
|
|
||||||
## Implementation Examples
|
## Implementation Examples
|
||||||
|
|
||||||
@@ -148,25 +174,31 @@ In `App.tsx`:
|
|||||||
### Component Level Protection
|
### Component Level Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const { canEdit } = usePagePermission('products');
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
function handleEdit() {
|
function handleAction() {
|
||||||
if (!canEdit()) {
|
if (!hasPermission('settings:user_management')) {
|
||||||
toast.error("You don't have permission");
|
toast.error("You don't have permission");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Edit logic
|
// Action logic
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### UI Element Protection
|
### UI Element Protection
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<PermissionButton
|
<PermissionGuard permission="settings:user_management">
|
||||||
page="products"
|
<button onClick={handleManageUsers}>
|
||||||
action="delete"
|
Manage Users
|
||||||
onClick={handleDelete}
|
</button>
|
||||||
>
|
</PermissionGuard>
|
||||||
Delete
|
|
||||||
</PermissionButton>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Page Access**: These permissions control which pages a user can navigate to
|
||||||
|
- **Settings Access**: These permissions control access to different sections within the Settings page
|
||||||
|
- **Admin Features**: Special permissions for administrative functions
|
||||||
|
- **CRUD Operations**: The application currently focuses on viewing and managing data rather than creating/editing/deleting individual records
|
||||||
|
- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,7 +13,7 @@ Not all of the information in this database is relevant as it's a direct export
|
|||||||
|
|
||||||
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
|
Server-side files should use similar conventions and the same technologies as the inventory-server (inventor-server root) and auth-server (inventory-server/auth). I will provide my current pm2 ecosystem file upon request for you to add the configuration for the new "chat-server". I use Caddy on the server and can provide my caddyfile to assist with configuring the api routes. All configuration and routes for the chat-server should go in the inventory-server/chat folder or subfolders you create.
|
||||||
|
|
||||||
The folder you see as inventory-server is actually a direct mount of the /var/www/html/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
|
The folder you see as inventory-server is actually a direct mount of the /var/www/inventory folder on the server. You can read and write files from there like usual, but any terminal commands for the server I will have to run myself.
|
||||||
|
|
||||||
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
|
The "Chat" page should be added to the main application sidebar and a similar page to the others should be created in inventory/src/pages. All other frontend pages should go in inventory/src/components/chat.
|
||||||
|
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
// ecosystem.config.js
|
|
||||||
const path = require('path');
|
|
||||||
const dotenv = require('dotenv');
|
|
||||||
|
|
||||||
// Load environment variables safely with error handling
|
|
||||||
const loadEnvFile = (envPath) => {
|
|
||||||
try {
|
|
||||||
console.log('Loading env from:', envPath);
|
|
||||||
const result = dotenv.config({ path: envPath });
|
|
||||||
if (result.error) {
|
|
||||||
console.warn(`Warning: .env file not found or invalid at ${envPath}:`, result.error.message);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
console.log('Env variables loaded from', envPath, ':', Object.keys(result.parsed || {}));
|
|
||||||
return result.parsed || {};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Warning: Error loading .env file at ${envPath}:`, error.message);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load environment variables for each server
|
|
||||||
const authEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/auth-server/.env'));
|
|
||||||
const aircallEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/aircall-server/.env'));
|
|
||||||
const klaviyoEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/klaviyo-server/.env'));
|
|
||||||
const metaEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/meta-server/.env'));
|
|
||||||
const googleAnalyticsEnv = require('dotenv').config({
|
|
||||||
path: path.resolve(__dirname, 'dashboard/google-server/.env')
|
|
||||||
}).parsed || {};
|
|
||||||
const typeformEnv = loadEnvFile(path.resolve(__dirname, 'dashboard/typeform-server/.env'));
|
|
||||||
const inventoryEnv = loadEnvFile(path.resolve(__dirname, 'inventory/.env'));
|
|
||||||
|
|
||||||
// Common log settings for all apps
|
|
||||||
const logSettings = {
|
|
||||||
log_rotate: true,
|
|
||||||
max_size: '10M',
|
|
||||||
retain: '10',
|
|
||||||
log_date_format: 'YYYY-MM-DD HH:mm:ss'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Common app settings
|
|
||||||
const commonSettings = {
|
|
||||||
instances: 1,
|
|
||||||
exec_mode: 'fork',
|
|
||||||
autorestart: true,
|
|
||||||
watch: false,
|
|
||||||
max_memory_restart: '1G',
|
|
||||||
time: true,
|
|
||||||
...logSettings,
|
|
||||||
ignore_watch: [
|
|
||||||
'node_modules',
|
|
||||||
'logs',
|
|
||||||
'.git',
|
|
||||||
'*.log'
|
|
||||||
],
|
|
||||||
min_uptime: 5000,
|
|
||||||
max_restarts: 5,
|
|
||||||
restart_delay: 4000,
|
|
||||||
listen_timeout: 50000,
|
|
||||||
kill_timeout: 5000,
|
|
||||||
node_args: '--max-old-space-size=1536'
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'auth-server',
|
|
||||||
script: './dashboard/auth-server/index.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3003,
|
|
||||||
...authEnv
|
|
||||||
},
|
|
||||||
error_file: 'dashboard/auth-server/logs/pm2/err.log',
|
|
||||||
out_file: 'dashboard/auth-server/logs/pm2/out.log',
|
|
||||||
log_file: 'dashboard/auth-server/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3003
|
|
||||||
},
|
|
||||||
env_development: {
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
PORT: 3003
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'aircall-server',
|
|
||||||
script: './dashboard/aircall-server/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
AIRCALL_PORT: 3002,
|
|
||||||
...aircallEnv
|
|
||||||
},
|
|
||||||
error_file: 'dashboard/aircall-server/logs/pm2/err.log',
|
|
||||||
out_file: 'dashboard/aircall-server/logs/pm2/out.log',
|
|
||||||
log_file: 'dashboard/aircall-server/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
AIRCALL_PORT: 3002
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'klaviyo-server',
|
|
||||||
script: './dashboard/klaviyo-server/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
KLAVIYO_PORT: 3004,
|
|
||||||
...klaviyoEnv
|
|
||||||
},
|
|
||||||
error_file: 'dashboard/klaviyo-server/logs/pm2/err.log',
|
|
||||||
out_file: 'dashboard/klaviyo-server/logs/pm2/out.log',
|
|
||||||
log_file: 'dashboard/klaviyo-server/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
KLAVIYO_PORT: 3004
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'meta-server',
|
|
||||||
script: './dashboard/meta-server/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3005,
|
|
||||||
...metaEnv
|
|
||||||
},
|
|
||||||
error_file: 'dashboard/meta-server/logs/pm2/err.log',
|
|
||||||
out_file: 'dashboard/meta-server/logs/pm2/out.log',
|
|
||||||
log_file: 'dashboard/meta-server/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3005
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gorgias-server",
|
|
||||||
script: "./dashboard/gorgias-server/server.js",
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "development",
|
|
||||||
PORT: 3006
|
|
||||||
},
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
PORT: 3006
|
|
||||||
},
|
|
||||||
error_file: "dashboard/logs/gorgias-server-error.log",
|
|
||||||
out_file: "dashboard/logs/gorgias-server-out.log",
|
|
||||||
log_file: "dashboard/logs/gorgias-server-combined.log",
|
|
||||||
time: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'google-server',
|
|
||||||
script: path.resolve(__dirname, 'dashboard/google-server/server.js'),
|
|
||||||
watch: false,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
GOOGLE_ANALYTICS_PORT: 3007,
|
|
||||||
...googleAnalyticsEnv
|
|
||||||
},
|
|
||||||
error_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/err.log'),
|
|
||||||
out_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/out.log'),
|
|
||||||
log_file: path.resolve(__dirname, 'dashboard/google-server/logs/pm2/combined.log'),
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
GOOGLE_ANALYTICS_PORT: 3007
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'typeform-server',
|
|
||||||
script: './dashboard/typeform-server/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
TYPEFORM_PORT: 3008,
|
|
||||||
...typeformEnv
|
|
||||||
},
|
|
||||||
error_file: 'dashboard/typeform-server/logs/pm2/err.log',
|
|
||||||
out_file: 'dashboard/typeform-server/logs/pm2/out.log',
|
|
||||||
log_file: 'dashboard/typeform-server/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
TYPEFORM_PORT: 3008
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'inventory-server',
|
|
||||||
script: './inventory/src/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3010,
|
|
||||||
...inventoryEnv
|
|
||||||
},
|
|
||||||
error_file: 'inventory/logs/pm2/err.log',
|
|
||||||
out_file: 'inventory/logs/pm2/out.log',
|
|
||||||
log_file: 'inventory/logs/pm2/combined.log',
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3010,
|
|
||||||
...inventoryEnv
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...commonSettings,
|
|
||||||
name: 'new-auth-server',
|
|
||||||
script: './inventory-server/auth/server.js',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
AUTH_PORT: 3011,
|
|
||||||
...inventoryEnv,
|
|
||||||
JWT_SECRET: process.env.JWT_SECRET
|
|
||||||
},
|
|
||||||
error_file: 'inventory-server/auth/logs/pm2/err.log',
|
|
||||||
out_file: 'inventory-server/auth/logs/pm2/out.log',
|
|
||||||
log_file: 'inventory-server/auth/logs/pm2/combined.log'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -1,100 +1,72 @@
|
|||||||
require('dotenv').config({ path: '../.env' });
|
import bcrypt from 'bcrypt';
|
||||||
const bcrypt = require('bcrypt');
|
import pg from 'pg';
|
||||||
const { Pool } = require('pg');
|
import inquirer from 'inquirer';
|
||||||
const inquirer = require('inquirer');
|
|
||||||
|
|
||||||
// Log connection details for debugging (remove in production)
|
const { Pool } = pg;
|
||||||
console.log('Attempting to connect with:', {
|
import { config as loadEnv } from 'dotenv';
|
||||||
host: process.env.DB_HOST,
|
import { fileURLToPath } from 'node:url';
|
||||||
user: process.env.DB_USER,
|
import { dirname, resolve as resolvePath } from 'node:path';
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: process.env.DB_PORT
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
});
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
loadEnv({ path: resolvePath(__dirname, '../.env') });
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: process.env.DB_PORT,
|
port: Number(process.env.DB_PORT) || 5432,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function promptUser() {
|
async function promptUser() {
|
||||||
const questions = [
|
return inquirer.prompt([
|
||||||
{
|
{
|
||||||
type: 'input',
|
type: 'input',
|
||||||
name: 'username',
|
name: 'username',
|
||||||
message: 'Enter username:',
|
message: 'Enter username:',
|
||||||
validate: (input) => {
|
validate: (input) => input.length >= 3 || 'Username must be at least 3 characters long',
|
||||||
if (input.length < 3) {
|
|
||||||
return 'Username must be at least 3 characters long';
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'password',
|
type: 'password',
|
||||||
name: 'password',
|
name: 'password',
|
||||||
message: 'Enter password:',
|
message: 'Enter password:',
|
||||||
mask: '*',
|
mask: '*',
|
||||||
validate: (input) => {
|
validate: (input) => input.length >= 8 || 'Password must be at least 8 characters long',
|
||||||
if (input.length < 8) {
|
|
||||||
return 'Password must be at least 8 characters long';
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'password',
|
type: 'password',
|
||||||
name: 'confirmPassword',
|
name: 'confirmPassword',
|
||||||
message: 'Confirm password:',
|
message: 'Confirm password:',
|
||||||
mask: '*',
|
mask: '*',
|
||||||
validate: (input, answers) => {
|
validate: (input, answers) => input === answers.password || 'Passwords do not match',
|
||||||
if (input !== answers.password) {
|
},
|
||||||
return 'Passwords do not match';
|
]);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return inquirer.prompt(questions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addUser() {
|
async function addUser() {
|
||||||
try {
|
try {
|
||||||
// Get user input
|
const { username, password } = await promptUser();
|
||||||
const answers = await promptUser();
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
const { username, password } = answers;
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const saltRounds = 10;
|
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
||||||
|
|
||||||
// Check if user already exists
|
|
||||||
const checkResult = await pool.query(
|
const checkResult = await pool.query(
|
||||||
'SELECT id FROM users WHERE username = $1',
|
'SELECT id FROM users WHERE username = $1',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (checkResult.rows.length > 0) {
|
if (checkResult.rows.length > 0) {
|
||||||
console.error('Error: Username already exists');
|
console.error('Error: Username already exists');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new user
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id',
|
'INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id',
|
||||||
[username, hashedPassword]
|
[username, hashedPassword]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`User ${username} created successfully with id ${result.rows[0].id}`);
|
console.log(`User ${username} created successfully with id ${result.rows[0].id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating user:', error);
|
console.error('Error creating user:', error);
|
||||||
console.error('Error details:', error.message);
|
if (error.code) console.error('Error code:', error.code);
|
||||||
if (error.code) {
|
|
||||||
console.error('Error code:', error.code);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+54
-52
@@ -18,6 +18,43 @@
|
|||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@inquirer/external-editor": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chardet": "^2.1.0",
|
||||||
|
"iconv-lite": "^0.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@inquirer/external-editor/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/node-pre-gyp": {
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
@@ -251,9 +288,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -345,9 +382,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chardet": {
|
"node_modules/chardet": {
|
||||||
"version": "0.7.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz",
|
||||||
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
|
"integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
@@ -700,20 +737,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/external-editor": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chardet": "^0.7.0",
|
|
||||||
"iconv-lite": "^0.4.24",
|
|
||||||
"tmp": "^0.0.33"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/figures": {
|
"node_modules/figures": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
|
||||||
@@ -1036,16 +1059,16 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/inquirer": {
|
"node_modules/inquirer": {
|
||||||
"version": "8.2.6",
|
"version": "8.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz",
|
||||||
"integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
|
"integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inquirer/external-editor": "^1.0.0",
|
||||||
"ansi-escapes": "^4.2.1",
|
"ansi-escapes": "^4.2.1",
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"cli-cursor": "^3.1.0",
|
"cli-cursor": "^3.1.0",
|
||||||
"cli-width": "^3.0.0",
|
"cli-width": "^3.0.0",
|
||||||
"external-editor": "^3.0.3",
|
|
||||||
"figures": "^3.0.0",
|
"figures": "^3.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mute-stream": "0.0.8",
|
"mute-stream": "0.0.8",
|
||||||
@@ -1374,16 +1397,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/morgan": {
|
"node_modules/morgan": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||||
"integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==",
|
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"basic-auth": "~2.0.1",
|
"basic-auth": "~2.0.1",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"on-finished": "~2.3.0",
|
"on-finished": "~2.3.0",
|
||||||
"on-headers": "~1.0.2"
|
"on-headers": "~1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
@@ -1510,9 +1533,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-headers": {
|
"node_modules/on-headers": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -1565,15 +1588,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/os-tmpdir": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -2109,18 +2123,6 @@
|
|||||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tmp": {
|
|
||||||
"version": "0.0.33",
|
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
|
||||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"os-tmpdir": "~1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
"name": "inventory-auth-server",
|
"name": "inventory-auth-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Authentication server for inventory management system",
|
"description": "Authentication server for inventory management system",
|
||||||
|
"type": "module",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"start": "node server.js",
|
||||||
|
"add-user": "node add-user.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
"inquirer": "^8.2.6",
|
"inquirer": "^8.2.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"pg": "^8.11.3",
|
||||||
"pg": "^8.11.3"
|
"pino": "^9.5.0",
|
||||||
|
"pino-http": "^10.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,12 @@
|
|||||||
// Get pool from global or create a new one if not available
|
export function createPermissionHelpers({ pool }) {
|
||||||
let pool;
|
|
||||||
if (typeof global.pool !== 'undefined') {
|
|
||||||
pool = global.pool;
|
|
||||||
} else {
|
|
||||||
// If global pool is not available, create a new connection
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
pool = new Pool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: process.env.DB_PORT,
|
|
||||||
});
|
|
||||||
console.log('Created new database pool in permissions.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a user has a specific permission
|
|
||||||
* @param {number} userId - The user ID to check
|
|
||||||
* @param {string} permissionCode - The permission code to check
|
|
||||||
* @returns {Promise<boolean>} - Whether the user has the permission
|
|
||||||
*/
|
|
||||||
async function checkPermission(userId, permissionCode) {
|
async function checkPermission(userId, permissionCode) {
|
||||||
try {
|
try {
|
||||||
// First check if the user is an admin
|
|
||||||
const adminResult = await pool.query(
|
const adminResult = await pool.query(
|
||||||
'SELECT is_admin FROM users WHERE id = $1',
|
'SELECT is_admin FROM users WHERE id = $1',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) return true;
|
||||||
|
|
||||||
// If user is admin, automatically grant permission
|
|
||||||
if (adminResult.rows.length > 0 && adminResult.rows[0].is_admin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise check for specific permission
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT COUNT(*) AS has_permission
|
`SELECT COUNT(*) AS has_permission
|
||||||
FROM user_permissions up
|
FROM user_permissions up
|
||||||
@@ -42,36 +14,26 @@ async function checkPermission(userId, permissionCode) {
|
|||||||
WHERE up.user_id = $1 AND p.code = $2`,
|
WHERE up.user_id = $1 AND p.code = $2`,
|
||||||
[userId, permissionCode]
|
[userId, permissionCode]
|
||||||
);
|
);
|
||||||
|
return Number(result.rows[0].has_permission) > 0;
|
||||||
return result.rows[0].has_permission > 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking permission:', error);
|
console.error('Error checking permission:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require a specific permission
|
|
||||||
* @param {string} permissionCode - The permission code required
|
|
||||||
* @returns {Function} - Express middleware function
|
|
||||||
*/
|
|
||||||
function requirePermission(permissionCode) {
|
function requirePermission(permissionCode) {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Check if user is authenticated
|
if (!req.user?.id) {
|
||||||
if (!req.user || !req.user.id) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = await checkPermission(req.user.id, permissionCode);
|
const hasPermission = await checkPermission(req.user.id, permissionCode);
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Insufficient permissions',
|
error: 'Insufficient permissions',
|
||||||
requiredPermission: permissionCode
|
requiredPermission: permissionCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Permission middleware error:', error);
|
console.error('Permission middleware error:', error);
|
||||||
@@ -80,31 +42,19 @@ function requirePermission(permissionCode) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all permissions for a user
|
|
||||||
* @param {number} userId - The user ID
|
|
||||||
* @returns {Promise<string[]>} - Array of permission codes
|
|
||||||
*/
|
|
||||||
async function getUserPermissions(userId) {
|
async function getUserPermissions(userId) {
|
||||||
try {
|
try {
|
||||||
// Check if user is admin
|
|
||||||
const adminResult = await pool.query(
|
const adminResult = await pool.query(
|
||||||
'SELECT is_admin FROM users WHERE id = $1',
|
'SELECT is_admin FROM users WHERE id = $1',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
if (adminResult.rows.length === 0) return [];
|
||||||
|
|
||||||
if (adminResult.rows.length === 0) {
|
if (adminResult.rows[0].is_admin) {
|
||||||
return [];
|
const allPermissions = await pool.query('SELECT code FROM permissions');
|
||||||
|
return allPermissions.rows.map((p) => p.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = adminResult.rows[0].is_admin;
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
// Admin gets all permissions
|
|
||||||
const allPermissions = await pool.query('SELECT code FROM permissions');
|
|
||||||
return allPermissions.rows.map(p => p.code);
|
|
||||||
} else {
|
|
||||||
// Get assigned permissions
|
|
||||||
const permissions = await pool.query(
|
const permissions = await pool.query(
|
||||||
`SELECT p.code
|
`SELECT p.code
|
||||||
FROM permissions p
|
FROM permissions p
|
||||||
@@ -112,17 +62,12 @@ async function getUserPermissions(userId) {
|
|||||||
WHERE up.user_id = $1`,
|
WHERE up.user_id = $1`,
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
return permissions.rows.map((p) => p.code);
|
||||||
return permissions.rows.map(p => p.code);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting user permissions:', error);
|
console.error('Error getting user permissions:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
return { checkPermission, requirePermission, getUserPermissions };
|
||||||
checkPermission,
|
}
|
||||||
requirePermission,
|
|
||||||
getUserPermissions
|
|
||||||
};
|
|
||||||
|
|||||||
+78
-274
@@ -1,108 +1,75 @@
|
|||||||
const express = require('express');
|
import express from 'express';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { createPermissionHelpers } from './permissions.js';
|
||||||
|
|
||||||
|
export function createAuthRoutes({ pool }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcrypt');
|
const { requirePermission, getUserPermissions } = createPermissionHelpers({ pool });
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const { requirePermission, getUserPermissions } = require('./permissions');
|
|
||||||
|
|
||||||
// Get pool from global or create a new one if not available
|
// Local authenticate(): used by user-management endpoints that need req.user populated
|
||||||
let pool;
|
// with id/username/email/is_admin. NOT the per-service authenticate() — that lives in
|
||||||
if (typeof global.pool !== 'undefined') {
|
// shared/auth/middleware.js and is used by downstream services. Auth-server's surface is
|
||||||
pool = global.pool;
|
// small enough that a local copy is fine; the security boundary is the JWT verify step.
|
||||||
} else {
|
async function authenticate(req, res, next) {
|
||||||
// If global pool is not available, create a new connection
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
pool = new Pool({
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
port: process.env.DB_PORT,
|
|
||||||
});
|
|
||||||
console.log('Created new database pool in routes.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication middleware
|
|
||||||
const authenticate = async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
const token = authHeader.split(' ')[1];
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
// Get user from database
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, is_admin FROM users WHERE id = $1',
|
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
|
||||||
[decoded.userId]
|
[decoded.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'User not found' });
|
return res.status(401).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach user to request
|
|
||||||
req.user = result.rows[0];
|
req.user = result.rows[0];
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Authentication error:', error);
|
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Login route
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
// Get user from database
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
'SELECT id, username, password, is_admin, is_active, rocket_chat_user_id FROM users WHERE username = $1',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
|
|
||||||
// Check if user is active
|
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
return res.status(403).json({ error: 'Account is inactive' });
|
return res.status(403).json({ error: 'Account is inactive' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
|
||||||
const validPassword = await bcrypt.compare(password, user.password);
|
const validPassword = await bcrypt.compare(password, user.password);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
||||||
[user.id]
|
[user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate JWT
|
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, username: user.username },
|
{ userId: user.id, username: user.username },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '8h' }
|
{ expiresIn: '8h' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get user permissions
|
|
||||||
const permissions = await getUserPermissions(user.id);
|
const permissions = await getUserPermissions(user.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin,
|
||||||
permissions
|
rocket_chat_user_id: user.rocket_chat_user_id,
|
||||||
}
|
permissions,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
@@ -110,17 +77,16 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current user
|
|
||||||
router.get('/me', authenticate, async (req, res) => {
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get user permissions
|
|
||||||
const permissions = await getUserPermissions(req.user.id);
|
const permissions = await getUserPermissions(req.user.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: req.user.id,
|
id: req.user.id,
|
||||||
username: req.user.username,
|
username: req.user.username,
|
||||||
|
email: req.user.email,
|
||||||
is_admin: req.user.is_admin,
|
is_admin: req.user.is_admin,
|
||||||
permissions
|
rocket_chat_user_id: req.user.rocket_chat_user_id,
|
||||||
|
permissions,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting current user:', error);
|
console.error('Error getting current user:', error);
|
||||||
@@ -128,15 +94,13 @@ router.get('/me', authenticate, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all users
|
|
||||||
router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => {
|
router.get('/users', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY username
|
ORDER BY username
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting users:', error);
|
console.error('Error getting users:', error);
|
||||||
@@ -144,23 +108,17 @@ router.get('/users', authenticate, requirePermission('view:users'), async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user with permissions
|
|
||||||
router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => {
|
router.get('/users/:id', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
// Get user details
|
|
||||||
const userResult = await pool.query(`
|
const userResult = await pool.query(`
|
||||||
SELECT id, username, email, is_admin, is_active, created_at, last_login
|
SELECT id, username, email, is_admin, is_active, rocket_chat_user_id, created_at, last_login
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
|
||||||
if (userResult.rows.length === 0) {
|
if (userResult.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user permissions
|
|
||||||
const permissionsResult = await pool.query(`
|
const permissionsResult = await pool.query(`
|
||||||
SELECT p.id, p.name, p.code, p.category, p.description
|
SELECT p.id, p.name, p.code, p.category, p.description
|
||||||
FROM permissions p
|
FROM permissions p
|
||||||
@@ -168,122 +126,54 @@ router.get('/users/:id', authenticate, requirePermission('view:users'), async (r
|
|||||||
WHERE up.user_id = $1
|
WHERE up.user_id = $1
|
||||||
ORDER BY p.category, p.name
|
ORDER BY p.category, p.name
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
|
res.json({
|
||||||
// Combine user and permissions
|
|
||||||
const user = {
|
|
||||||
...userResult.rows[0],
|
...userResult.rows[0],
|
||||||
permissions: permissionsResult.rows
|
permissions: permissionsResult.rows,
|
||||||
};
|
});
|
||||||
|
|
||||||
res.json(user);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting user:', error);
|
console.error('Error getting user:', error);
|
||||||
res.status(500).json({ error: 'Server error' });
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create new user
|
|
||||||
router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => {
|
router.post('/users', authenticate, requirePermission('create:users'), async (req, res) => {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body;
|
||||||
|
|
||||||
console.log("Create user request:", {
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
is_admin,
|
|
||||||
is_active,
|
|
||||||
permissions: permissions || []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return res.status(400).json({ error: 'Username and password are required' });
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if username is taken
|
|
||||||
const existingUser = await client.query(
|
const existingUser = await client.query(
|
||||||
'SELECT id FROM users WHERE username = $1',
|
'SELECT id FROM users WHERE username = $1',
|
||||||
[username]
|
[username]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingUser.rows.length > 0) {
|
if (existingUser.rows.length > 0) {
|
||||||
return res.status(400).json({ error: 'Username already exists' });
|
return res.status(400).json({ error: 'Username already exists' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
// Hash password
|
const rcUserId = rocket_chat_user_id ? parseInt(rocket_chat_user_id, 10) : null;
|
||||||
const saltRounds = 10;
|
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
||||||
|
|
||||||
// Insert new user
|
|
||||||
const userResult = await client.query(`
|
const userResult = await client.query(`
|
||||||
INSERT INTO users (username, email, password, is_admin, is_active, created_at)
|
INSERT INTO users (username, email, password, is_admin, is_active, rocket_chat_user_id, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false]);
|
`, [username, email || null, hashedPassword, !!is_admin, is_active !== false, rcUserId]);
|
||||||
|
|
||||||
const userId = userResult.rows[0].id;
|
const userId = userResult.rows[0].id;
|
||||||
|
|
||||||
// Assign permissions if provided and not admin
|
|
||||||
if (!is_admin && Array.isArray(permissions) && permissions.length > 0) {
|
if (!is_admin && Array.isArray(permissions) && permissions.length > 0) {
|
||||||
console.log("Adding permissions for new user:", userId);
|
const permissionIds = normalizePermissionIds(permissions);
|
||||||
console.log("Permissions received:", permissions);
|
|
||||||
|
|
||||||
// Check permission format
|
|
||||||
const permissionIds = permissions.map(p => {
|
|
||||||
if (typeof p === 'object' && p.id) {
|
|
||||||
console.log("Permission is an object with ID:", p.id);
|
|
||||||
return parseInt(p.id, 10);
|
|
||||||
} else if (typeof p === 'number') {
|
|
||||||
console.log("Permission is a number:", p);
|
|
||||||
return p;
|
|
||||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
|
||||||
console.log("Permission is a string that can be parsed as a number:", p);
|
|
||||||
return parseInt(p, 10);
|
|
||||||
} else {
|
|
||||||
console.log("Unknown permission format:", typeof p, p);
|
|
||||||
// If it's a permission code, we need to look up the ID
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).filter(id => id !== null);
|
|
||||||
|
|
||||||
console.log("Filtered permission IDs:", permissionIds);
|
|
||||||
|
|
||||||
if (permissionIds.length > 0) {
|
if (permissionIds.length > 0) {
|
||||||
const permissionValues = permissionIds
|
await client.query(
|
||||||
.map(permId => `(${userId}, ${permId})`)
|
`INSERT INTO user_permissions (user_id, permission_id)
|
||||||
.join(',');
|
SELECT $1, unnest($2::int[])
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
console.log("Inserting permission values:", permissionValues);
|
[userId, permissionIds]
|
||||||
|
);
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO user_permissions (user_id, permission_id)
|
|
||||||
VALUES ${permissionValues}
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`);
|
|
||||||
console.log("Successfully inserted permissions for new user:", userId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error inserting permissions for new user:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log("No valid permission IDs found to insert for new user");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("Not adding permissions: is_admin =", is_admin, "permissions array:", Array.isArray(permissions), "length:", permissions ? permissions.length : 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
res.status(201).json({ id: userId, message: 'User created successfully' });
|
||||||
res.status(201).json({
|
|
||||||
id: userId,
|
|
||||||
message: 'User created successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error('Error creating user:', error);
|
console.error('Error creating user:', error);
|
||||||
@@ -293,146 +183,64 @@ router.post('/users', authenticate, requirePermission('create:users'), async (re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user
|
|
||||||
router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => {
|
router.put('/users/:id', authenticate, requirePermission('edit:users'), async (req, res) => {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
const { username, email, password, is_admin, is_active, permissions } = req.body;
|
const { username, email, password, is_admin, is_active, rocket_chat_user_id, permissions } = req.body;
|
||||||
|
|
||||||
console.log("Update user request:", {
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
is_admin,
|
|
||||||
is_active,
|
|
||||||
permissions: permissions || []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if user exists
|
|
||||||
const userExists = await client.query(
|
|
||||||
'SELECT id FROM users WHERE id = $1',
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const userExists = await client.query('SELECT id FROM users WHERE id = $1', [userId]);
|
||||||
if (userExists.rows.length === 0) {
|
if (userExists.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start transaction
|
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// Build update fields
|
|
||||||
const updateFields = [];
|
const updateFields = [];
|
||||||
const updateValues = [userId]; // First parameter is the user ID
|
const updateValues = [userId];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) { updateFields.push(`username = $${paramIndex++}`); updateValues.push(username); }
|
||||||
updateFields.push(`username = $${paramIndex++}`);
|
if (email !== undefined) { updateFields.push(`email = $${paramIndex++}`); updateValues.push(email || null); }
|
||||||
updateValues.push(username);
|
if (is_admin !== undefined) { updateFields.push(`is_admin = $${paramIndex++}`); updateValues.push(!!is_admin); }
|
||||||
|
if (is_active !== undefined) { updateFields.push(`is_active = $${paramIndex++}`); updateValues.push(!!is_active); }
|
||||||
|
if (rocket_chat_user_id !== undefined) {
|
||||||
|
updateFields.push(`rocket_chat_user_id = $${paramIndex++}`);
|
||||||
|
updateValues.push(rocket_chat_user_id ? parseInt(rocket_chat_user_id, 10) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email !== undefined) {
|
|
||||||
updateFields.push(`email = $${paramIndex++}`);
|
|
||||||
updateValues.push(email || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_admin !== undefined) {
|
|
||||||
updateFields.push(`is_admin = $${paramIndex++}`);
|
|
||||||
updateValues.push(!!is_admin);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_active !== undefined) {
|
|
||||||
updateFields.push(`is_active = $${paramIndex++}`);
|
|
||||||
updateValues.push(!!is_active);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update password if provided
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const saltRounds = 10;
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
|
||||||
updateFields.push(`password = $${paramIndex++}`);
|
updateFields.push(`password = $${paramIndex++}`);
|
||||||
updateValues.push(hashedPassword);
|
updateValues.push(hashedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user if there are fields to update
|
|
||||||
if (updateFields.length > 0) {
|
if (updateFields.length > 0) {
|
||||||
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
UPDATE users
|
UPDATE users SET ${updateFields.join(', ')} WHERE id = $1
|
||||||
SET ${updateFields.join(', ')}
|
|
||||||
WHERE id = $1
|
|
||||||
`, updateValues);
|
`, updateValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update permissions if provided
|
|
||||||
if (Array.isArray(permissions)) {
|
if (Array.isArray(permissions)) {
|
||||||
console.log("Updating permissions for user:", userId);
|
await client.query('DELETE FROM user_permissions WHERE user_id = $1', [userId]);
|
||||||
console.log("Permissions received:", permissions);
|
const newIsAdmin = is_admin !== undefined
|
||||||
|
? is_admin
|
||||||
// First remove existing permissions
|
: (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin;
|
||||||
await client.query(
|
|
||||||
'DELETE FROM user_permissions WHERE user_id = $1',
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
console.log("Deleted existing permissions for user:", userId);
|
|
||||||
|
|
||||||
// Add new permissions if any and not admin
|
|
||||||
const newIsAdmin = is_admin !== undefined ? is_admin : (await client.query('SELECT is_admin FROM users WHERE id = $1', [userId])).rows[0].is_admin;
|
|
||||||
|
|
||||||
console.log("User is admin:", newIsAdmin);
|
|
||||||
|
|
||||||
if (!newIsAdmin && permissions.length > 0) {
|
if (!newIsAdmin && permissions.length > 0) {
|
||||||
console.log("Adding permissions:", permissions);
|
const permissionIds = normalizePermissionIds(permissions);
|
||||||
|
|
||||||
// Check permission format
|
|
||||||
const permissionIds = permissions.map(p => {
|
|
||||||
if (typeof p === 'object' && p.id) {
|
|
||||||
console.log("Permission is an object with ID:", p.id);
|
|
||||||
return parseInt(p.id, 10);
|
|
||||||
} else if (typeof p === 'number') {
|
|
||||||
console.log("Permission is a number:", p);
|
|
||||||
return p;
|
|
||||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
|
||||||
console.log("Permission is a string that can be parsed as a number:", p);
|
|
||||||
return parseInt(p, 10);
|
|
||||||
} else {
|
|
||||||
console.log("Unknown permission format:", typeof p, p);
|
|
||||||
// If it's a permission code, we need to look up the ID
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}).filter(id => id !== null);
|
|
||||||
|
|
||||||
console.log("Filtered permission IDs:", permissionIds);
|
|
||||||
|
|
||||||
if (permissionIds.length > 0) {
|
if (permissionIds.length > 0) {
|
||||||
const permissionValues = permissionIds
|
await client.query(
|
||||||
.map(permId => `(${userId}, ${permId})`)
|
`INSERT INTO user_permissions (user_id, permission_id)
|
||||||
.join(',');
|
SELECT $1, unnest($2::int[])
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
console.log("Inserting permission values:", permissionValues);
|
[userId, permissionIds]
|
||||||
|
);
|
||||||
try {
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO user_permissions (user_id, permission_id)
|
|
||||||
VALUES ${permissionValues}
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`);
|
|
||||||
console.log("Successfully inserted permissions for user:", userId);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error inserting permissions:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("No valid permission IDs found to insert");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
res.json({ message: 'User updated successfully' });
|
res.json({ message: 'User updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
@@ -443,26 +251,19 @@ router.put('/users/:id', authenticate, requirePermission('edit:users'), async (r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete user
|
|
||||||
router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => {
|
router.delete('/users/:id', authenticate, requirePermission('delete:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
// Check that user is not deleting themselves
|
|
||||||
if (req.user.id === parseInt(userId, 10)) {
|
if (req.user.id === parseInt(userId, 10)) {
|
||||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete user (this will cascade to user_permissions due to FK constraints)
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'DELETE FROM users WHERE id = $1 RETURNING id',
|
'DELETE FROM users WHERE id = $1 RETURNING id',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: 'User deleted successfully' });
|
res.json({ message: 'User deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting user:', error);
|
console.error('Error deleting user:', error);
|
||||||
@@ -470,23 +271,18 @@ router.delete('/users/:id', authenticate, requirePermission('delete:users'), asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all permissions grouped by category
|
|
||||||
router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => {
|
router.get('/permissions/categories', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT category, json_agg(
|
SELECT category, json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'id', id,
|
'id', id, 'name', name, 'code', code, 'description', description
|
||||||
'name', name,
|
|
||||||
'code', code,
|
|
||||||
'description', description
|
|
||||||
) ORDER BY name
|
) ORDER BY name
|
||||||
) as permissions
|
) as permissions
|
||||||
FROM permissions
|
FROM permissions
|
||||||
GROUP BY category
|
GROUP BY category
|
||||||
ORDER BY category
|
ORDER BY category
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting permissions:', error);
|
console.error('Error getting permissions:', error);
|
||||||
@@ -494,15 +290,11 @@ router.get('/permissions/categories', authenticate, requirePermission('view:user
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all permissions
|
|
||||||
router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => {
|
router.get('/permissions', authenticate, requirePermission('view:users'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT *
|
SELECT * FROM permissions ORDER BY category, name
|
||||||
FROM permissions
|
|
||||||
ORDER BY category, name
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json(result.rows);
|
res.json(result.rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting permissions:', error);
|
console.error('Error getting permissions:', error);
|
||||||
@@ -510,4 +302,16 @@ router.get('/permissions', authenticate, requirePermission('view:users'), async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissionIds(permissions) {
|
||||||
|
return permissions
|
||||||
|
.map((p) => {
|
||||||
|
if (typeof p === 'object' && p?.id) return parseInt(p.id, 10);
|
||||||
|
if (typeof p === 'number') return p;
|
||||||
|
if (typeof p === 'string' && !Number.isNaN(parseInt(p, 10))) return parseInt(p, 10);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((id) => id !== null && !Number.isNaN(id));
|
||||||
|
}
|
||||||
|
|||||||
+57
-137
@@ -1,164 +1,84 @@
|
|||||||
require('dotenv').config({ path: '../.env' });
|
import 'dotenv/config';
|
||||||
const express = require('express');
|
import express from 'express';
|
||||||
const cors = require('cors');
|
import cors from 'cors';
|
||||||
const bcrypt = require('bcrypt');
|
import pg from 'pg';
|
||||||
const jwt = require('jsonwebtoken');
|
import { fileURLToPath } from 'node:url';
|
||||||
const { Pool } = require('pg');
|
|
||||||
const morgan = require('morgan');
|
|
||||||
const authRoutes = require('./routes');
|
|
||||||
|
|
||||||
// Log startup configuration
|
const { Pool } = pg;
|
||||||
console.log('Starting auth server with config:', {
|
import { dirname, resolve as resolvePath } from 'node:path';
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
|
||||||
|
import { corsOptions } from '../shared/cors/policy.js';
|
||||||
|
import { requestLog } from '../shared/logging/request-log.js';
|
||||||
|
import { logger } from '../shared/logging/logger.js';
|
||||||
|
import { errorHandler } from '../shared/errors/handler.js';
|
||||||
|
import { loginLimiter, verifyLimiter } from '../shared/rate-limit/login.js';
|
||||||
|
import { extractBearerToken, verifyToken, TokenError } from '../shared/auth/verify.js';
|
||||||
|
|
||||||
|
import { createAuthRoutes } from './routes.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// auth/ lives at inventory-server/auth/, so .env one level up
|
||||||
|
loadEnv({ path: resolvePath(__dirname, '../.env') });
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
logger.error('JWT_SECRET is not set; refusing to start');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: process.env.DB_PORT,
|
port: process.env.DB_PORT,
|
||||||
auth_port: process.env.AUTH_PORT
|
auth_port: process.env.AUTH_PORT,
|
||||||
});
|
}, 'starting auth server');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.AUTH_PORT || 3011;
|
const port = Number(process.env.AUTH_PORT) || 3011;
|
||||||
|
|
||||||
// Database configuration
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: process.env.DB_PORT,
|
port: Number(process.env.DB_PORT) || 5432,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make pool available globally
|
app.use(requestLog());
|
||||||
global.pool = pool;
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(cors(corsOptions));
|
||||||
// Middleware
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(morgan('combined'));
|
|
||||||
app.use(cors({
|
|
||||||
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw'],
|
|
||||||
credentials: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Login endpoint
|
|
||||||
app.post('/login', async (req, res) => {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
|
|
||||||
|
// 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 independently; these headers are informational.
|
||||||
|
app.all('/verify', verifyLimiter, (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get user from database
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
const result = await pool.query(
|
const decoded = verifyToken(token, process.env.JWT_SECRET);
|
||||||
'SELECT id, username, password, is_admin, is_active FROM users WHERE username = $1',
|
res.set('X-User-Id', String(decoded.userId));
|
||||||
[username]
|
if (decoded.username) res.set('X-User-Username', decoded.username);
|
||||||
);
|
res.status(200).end();
|
||||||
|
} catch (err) {
|
||||||
const user = result.rows[0];
|
if (err instanceof TokenError) {
|
||||||
|
return res.status(401).json({ error: err.message });
|
||||||
// Check if user exists and password is correct
|
|
||||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is active
|
|
||||||
if (!user.is_active) {
|
|
||||||
return res.status(403).json({ error: 'Account is inactive' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
const token = jwt.sign(
|
|
||||||
{ userId: user.id, username: user.username },
|
|
||||||
process.env.JWT_SECRET,
|
|
||||||
{ expiresIn: '24h' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get user permissions for the response
|
|
||||||
const permissionsResult = await pool.query(`
|
|
||||||
SELECT code
|
|
||||||
FROM permissions p
|
|
||||||
JOIN user_permissions up ON p.id = up.permission_id
|
|
||||||
WHERE up.user_id = $1
|
|
||||||
`, [user.id]);
|
|
||||||
|
|
||||||
const permissions = permissionsResult.rows.map(row => row.code);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
token,
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
is_admin: user.is_admin,
|
|
||||||
permissions: user.is_admin ? [] : permissions
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// User info endpoint
|
|
||||||
app.get('/me', async (req, res) => {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
|
|
||||||
// Get user details from database
|
|
||||||
const userResult = await pool.query(
|
|
||||||
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
|
|
||||||
[decoded.userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userResult.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userResult.rows[0];
|
|
||||||
|
|
||||||
// Get user permissions
|
|
||||||
let permissions = [];
|
|
||||||
if (!user.is_admin) {
|
|
||||||
const permissionsResult = await pool.query(`
|
|
||||||
SELECT code
|
|
||||||
FROM permissions p
|
|
||||||
JOIN user_permissions up ON p.id = up.permission_id
|
|
||||||
WHERE up.user_id = $1
|
|
||||||
`, [user.id]);
|
|
||||||
|
|
||||||
permissions = permissionsResult.rows.map(row => row.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
is_admin: user.is_admin,
|
|
||||||
permissions: permissions
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Token verification error:', error);
|
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount all routes from routes.js
|
// Login route gets its own rate limiter to slow credential stuffing.
|
||||||
app.use('/', authRoutes);
|
app.use('/login', loginLimiter);
|
||||||
|
|
||||||
// Health check endpoint
|
// Mount user-management + /login + /me from routes.js
|
||||||
app.get('/health', (req, res) => {
|
app.use('/', createAuthRoutes({ pool }));
|
||||||
res.json({ status: 'healthy' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error handling middleware
|
app.get('/health', (req, res) => res.json({ status: 'healthy' }));
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error(err.stack);
|
app.use(errorHandler);
|
||||||
res.status(500).json({ error: 'Something broke!' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Auth server running on port ${port}`);
|
logger.info({ port }, 'auth server listening');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- PostgreSQL Database Creation Script for New Server
|
||||||
|
-- Run as: sudo -u postgres psql -f create-new-database.sql
|
||||||
|
|
||||||
|
-- Terminate all connections to the database (if it exists)
|
||||||
|
SELECT pg_terminate_backend(pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = 'rocketchat_converted' AND pid <> pg_backend_pid();
|
||||||
|
|
||||||
|
-- Drop the database if it exists
|
||||||
|
DROP DATABASE IF EXISTS rocketchat_converted;
|
||||||
|
|
||||||
|
-- Create fresh database
|
||||||
|
CREATE DATABASE rocketchat_converted;
|
||||||
|
|
||||||
|
-- Create user (if not exists) - UPDATE PASSWORD BEFORE RUNNING!
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'rocketchat_user') THEN
|
||||||
|
CREATE USER rocketchat_user WITH PASSWORD 'HKjLgt23gWuPXzEAn3rW';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Grant database privileges
|
||||||
|
GRANT CONNECT ON DATABASE rocketchat_converted TO rocketchat_user;
|
||||||
|
GRANT CREATE ON DATABASE rocketchat_converted TO rocketchat_user;
|
||||||
|
|
||||||
|
-- Connect to the new database
|
||||||
|
\c rocketchat_converted;
|
||||||
|
|
||||||
|
-- Grant schema privileges
|
||||||
|
GRANT CREATE ON SCHEMA public TO rocketchat_user;
|
||||||
|
GRANT USAGE ON SCHEMA public TO rocketchat_user;
|
||||||
|
|
||||||
|
-- Grant privileges on all future tables and sequences
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO rocketchat_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO rocketchat_user;
|
||||||
|
|
||||||
|
-- Display success message
|
||||||
|
\echo 'Database created successfully!'
|
||||||
|
\echo 'IMPORTANT: Update the password for rocketchat_user before proceeding'
|
||||||
|
\echo 'Next steps:'
|
||||||
|
\echo '1. Update the password in this file'
|
||||||
|
\echo '2. Run export-chat-data.sh on your current server'
|
||||||
|
\echo '3. Transfer the exported files to this server'
|
||||||
|
\echo '4. Run import-chat-data.sh on this server'
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Chat Database Export Script
|
||||||
|
# This script exports the chat database schema and data for migration
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "🚀 Starting chat database export..."
|
||||||
|
|
||||||
|
# Configuration - Update these values for your setup
|
||||||
|
DB_HOST="${CHAT_DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${CHAT_DB_PORT:-5432}"
|
||||||
|
DB_NAME="${CHAT_DB_NAME:-rocketchat_converted}"
|
||||||
|
DB_USER="${CHAT_DB_USER:-rocketchat_user}"
|
||||||
|
|
||||||
|
# Check if database connection info is available
|
||||||
|
if [ -z "$CHAT_DB_PASSWORD" ]; then
|
||||||
|
echo "⚠️ CHAT_DB_PASSWORD environment variable not set"
|
||||||
|
echo "Please set it with: export CHAT_DB_PASSWORD='your_password'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📊 Database: $DB_NAME on $DB_HOST:$DB_PORT"
|
||||||
|
|
||||||
|
# Create export directory
|
||||||
|
EXPORT_DIR="chat-migration-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$EXPORT_DIR"
|
||||||
|
|
||||||
|
echo "📁 Export directory: $EXPORT_DIR"
|
||||||
|
|
||||||
|
# Export database schema
|
||||||
|
echo "📋 Exporting database schema..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" pg_dump \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--schema-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
-f "$EXPORT_DIR/chat-schema.sql"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Schema exported successfully"
|
||||||
|
else
|
||||||
|
echo "❌ Schema export failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Export database data
|
||||||
|
echo "💾 Exporting database data..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" pg_dump \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
--data-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
--disable-triggers \
|
||||||
|
--column-inserts \
|
||||||
|
-f "$EXPORT_DIR/chat-data.sql"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Data exported successfully"
|
||||||
|
else
|
||||||
|
echo "❌ Data export failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Export file uploads and avatars
|
||||||
|
echo "📎 Exporting chat files (uploads and avatars)..."
|
||||||
|
if [ -d "db-convert/db/files" ]; then
|
||||||
|
cd db-convert/db
|
||||||
|
tar -czf "../../$EXPORT_DIR/chat-files.tar.gz" files/
|
||||||
|
cd ../..
|
||||||
|
echo "✅ Files exported successfully"
|
||||||
|
else
|
||||||
|
echo "⚠️ No files directory found at db-convert/db/files"
|
||||||
|
echo " This is normal if you have no file uploads"
|
||||||
|
touch "$EXPORT_DIR/chat-files.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get table statistics for verification
|
||||||
|
echo "📈 Generating export statistics..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" psql \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
-c "
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
n_tup_ins as inserted_rows,
|
||||||
|
n_tup_upd as updated_rows,
|
||||||
|
n_tup_del as deleted_rows,
|
||||||
|
n_live_tup as live_rows,
|
||||||
|
n_dead_tup as dead_rows
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
ORDER BY n_live_tup DESC;
|
||||||
|
" > "$EXPORT_DIR/table-stats.txt"
|
||||||
|
|
||||||
|
# Create export summary
|
||||||
|
cat > "$EXPORT_DIR/export-summary.txt" << EOF
|
||||||
|
Chat Database Export Summary
|
||||||
|
===========================
|
||||||
|
|
||||||
|
Export Date: $(date)
|
||||||
|
Database: $DB_NAME
|
||||||
|
Host: $DB_HOST:$DB_PORT
|
||||||
|
User: $DB_USER
|
||||||
|
|
||||||
|
Files Generated:
|
||||||
|
- chat-schema.sql: Database schema (tables, indexes, constraints)
|
||||||
|
- chat-data.sql: All table data
|
||||||
|
- chat-files.tar.gz: Uploaded files and avatars
|
||||||
|
- table-stats.txt: Database statistics
|
||||||
|
- export-summary.txt: This summary
|
||||||
|
|
||||||
|
Next Steps:
|
||||||
|
1. Transfer these files to your new server
|
||||||
|
2. Run create-new-database.sql on the new server first
|
||||||
|
3. Run import-chat-data.sh on the new server
|
||||||
|
4. Update your application configuration
|
||||||
|
5. Run verify-migration.js to validate the migration
|
||||||
|
|
||||||
|
Important Notes:
|
||||||
|
- Keep these files secure as they contain your chat data
|
||||||
|
- Ensure the new server has enough disk space
|
||||||
|
- Plan for application downtime during the migration
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Export completed successfully!"
|
||||||
|
echo "📁 Files are in: $EXPORT_DIR/"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Export Summary:"
|
||||||
|
ls -lh "$EXPORT_DIR/"
|
||||||
|
echo ""
|
||||||
|
echo "🚚 Next steps:"
|
||||||
|
echo "1. Transfer the $EXPORT_DIR/ directory to your new server"
|
||||||
|
echo "2. Run create-new-database.sql on the new server (update password first!)"
|
||||||
|
echo "3. Run import-chat-data.sh on the new server"
|
||||||
|
echo ""
|
||||||
|
echo "💡 To transfer files to new server:"
|
||||||
|
echo " scp -r $EXPORT_DIR/ user@new-server:/tmp/"
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Chat Database Import Script
|
||||||
|
# This script imports the chat database schema and data on the new server
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
echo "🚀 Starting chat database import..."
|
||||||
|
|
||||||
|
# Configuration - Update these values for your new server
|
||||||
|
DB_HOST="${CHAT_DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${CHAT_DB_PORT:-5432}"
|
||||||
|
DB_NAME="${CHAT_DB_NAME:-rocketchat_converted}"
|
||||||
|
DB_USER="${CHAT_DB_USER:-rocketchat_user}"
|
||||||
|
|
||||||
|
# Check if database connection info is available
|
||||||
|
if [ -z "$CHAT_DB_PASSWORD" ]; then
|
||||||
|
echo "⚠️ CHAT_DB_PASSWORD environment variable not set"
|
||||||
|
echo "Please set it with: export CHAT_DB_PASSWORD='your_password'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the migration directory
|
||||||
|
MIGRATION_DIR=""
|
||||||
|
if [ -d "/tmp" ]; then
|
||||||
|
MIGRATION_DIR=$(find /tmp -maxdepth 1 -name "chat-migration-*" -type d | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$MIGRATION_DIR" ]; then
|
||||||
|
echo "❌ No migration directory found in /tmp/"
|
||||||
|
echo "Please specify the migration directory:"
|
||||||
|
read -p "Enter full path to migration directory: " MIGRATION_DIR
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$MIGRATION_DIR" ]; then
|
||||||
|
echo "❌ Migration directory not found: $MIGRATION_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📁 Using migration directory: $MIGRATION_DIR"
|
||||||
|
echo "📊 Target database: $DB_NAME on $DB_HOST:$DB_PORT"
|
||||||
|
|
||||||
|
# Verify required files exist
|
||||||
|
REQUIRED_FILES=("chat-schema.sql" "chat-data.sql" "chat-files.tar.gz")
|
||||||
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
|
if [ ! -f "$MIGRATION_DIR/$file" ]; then
|
||||||
|
echo "❌ Required file not found: $MIGRATION_DIR/$file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✅ All required files found"
|
||||||
|
|
||||||
|
# Test database connection
|
||||||
|
echo "🔗 Testing database connection..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" psql \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
-c "SELECT version();" > /dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Database connection successful"
|
||||||
|
else
|
||||||
|
echo "❌ Database connection failed"
|
||||||
|
echo "Please ensure:"
|
||||||
|
echo " 1. PostgreSQL is running"
|
||||||
|
echo " 2. Database '$DB_NAME' exists"
|
||||||
|
echo " 3. User '$DB_USER' has access"
|
||||||
|
echo " 4. Password is correct"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Import database schema
|
||||||
|
echo "📋 Importing database schema..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" psql \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
-f "$MIGRATION_DIR/chat-schema.sql"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Schema imported successfully"
|
||||||
|
else
|
||||||
|
echo "❌ Schema import failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Import database data
|
||||||
|
echo "💾 Importing database data..."
|
||||||
|
echo " This may take a while depending on data size..."
|
||||||
|
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" psql \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
-f "$MIGRATION_DIR/chat-data.sql"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Data imported successfully"
|
||||||
|
else
|
||||||
|
echo "❌ Data import failed"
|
||||||
|
echo "Check the error messages above for details"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create files directory and import files
|
||||||
|
echo "📎 Setting up files directory..."
|
||||||
|
mkdir -p "db-convert/db"
|
||||||
|
|
||||||
|
if [ -s "$MIGRATION_DIR/chat-files.tar.gz" ]; then
|
||||||
|
echo "📂 Extracting chat files..."
|
||||||
|
cd db-convert/db
|
||||||
|
tar -xzf "$MIGRATION_DIR/chat-files.tar.gz"
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
# Set proper permissions
|
||||||
|
if [ -d "db-convert/db/files" ]; then
|
||||||
|
chmod -R 755 db-convert/db/files
|
||||||
|
echo "✅ Files imported and permissions set"
|
||||||
|
else
|
||||||
|
echo "⚠️ Files directory not created properly"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ No files to import (empty archive)"
|
||||||
|
mkdir -p "db-convert/db/files/uploads"
|
||||||
|
mkdir -p "db-convert/db/files/avatars"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get final table statistics
|
||||||
|
echo "📈 Generating import statistics..."
|
||||||
|
PGPASSWORD="$CHAT_DB_PASSWORD" psql \
|
||||||
|
-h "$DB_HOST" \
|
||||||
|
-p "$DB_PORT" \
|
||||||
|
-U "$DB_USER" \
|
||||||
|
-d "$DB_NAME" \
|
||||||
|
-c "
|
||||||
|
SELECT
|
||||||
|
tablename,
|
||||||
|
n_live_tup as row_count
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
ORDER BY n_live_tup DESC;
|
||||||
|
"
|
||||||
|
|
||||||
|
# Create import summary
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Import completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Import Summary:"
|
||||||
|
echo " Database: $DB_NAME"
|
||||||
|
echo " Host: $DB_HOST:$DB_PORT"
|
||||||
|
echo " Files location: $(pwd)/db-convert/db/files/"
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Next steps:"
|
||||||
|
echo "1. Update your application configuration to use this database"
|
||||||
|
echo "2. Run verify-migration.js to validate the migration"
|
||||||
|
echo "3. Test your application thoroughly"
|
||||||
|
echo "4. Update DNS/load balancer to point to new server"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Important:"
|
||||||
|
echo "- Keep the original data as backup until migration is fully validated"
|
||||||
|
echo "- Monitor the application closely after switching"
|
||||||
|
echo "- Have a rollback plan ready"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Chat Database Migration Guide
|
||||||
|
|
||||||
|
This guide will help you migrate your chat database from the current server to a new PostgreSQL server.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Your chat system uses:
|
||||||
|
- Database: `rocketchat_converted` (PostgreSQL)
|
||||||
|
- Main tables: users, message, room, uploads, avatars, subscription
|
||||||
|
- File storage: db-convert/db/files/ directory with uploads and avatars
|
||||||
|
- Environment configuration for database connection
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
### 1. Pre-Migration Setup
|
||||||
|
|
||||||
|
On your **new server**, ensure PostgreSQL is installed and running:
|
||||||
|
```bash
|
||||||
|
# Install PostgreSQL (if not already done)
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Start PostgreSQL service
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Database Schema on New Server
|
||||||
|
|
||||||
|
Run the provided migration script:
|
||||||
|
```bash
|
||||||
|
# On new server
|
||||||
|
sudo -u postgres psql -f create-new-database.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Export Data from Current Server
|
||||||
|
|
||||||
|
Run the export script:
|
||||||
|
```bash
|
||||||
|
# On current server
|
||||||
|
./export-chat-data.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create:
|
||||||
|
- `chat-schema.sql` - Database schema
|
||||||
|
- `chat-data.sql` - All table data
|
||||||
|
- `chat-files.tar.gz` - All uploaded files and avatars
|
||||||
|
|
||||||
|
### 4. Transfer Data to New Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy files to new server
|
||||||
|
scp chat-schema.sql chat-data.sql chat-files.tar.gz user@new-server:/tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Import Data on New Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On new server
|
||||||
|
./import-chat-data.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update Configuration
|
||||||
|
|
||||||
|
Update your environment variables to point to the new database server.
|
||||||
|
|
||||||
|
### 7. Verify Migration
|
||||||
|
|
||||||
|
Run the verification script to ensure everything transferred correctly:
|
||||||
|
```bash
|
||||||
|
node verify-migration.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Provided
|
||||||
|
|
||||||
|
1. `create-new-database.sql` - Creates database and user on new server
|
||||||
|
2. `export-chat-data.sh` - Exports data from current server
|
||||||
|
3. `import-chat-data.sh` - Imports data to new server
|
||||||
|
4. `verify-migration.js` - Verifies data integrity
|
||||||
|
5. `update-config-template.env` - Template for new configuration
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Backup first**: Always backup your current database before migration
|
||||||
|
- **Downtime**: Plan for application downtime during migration
|
||||||
|
- **File permissions**: Ensure file permissions are preserved during transfer
|
||||||
|
- **Network access**: Ensure new server can accept connections from your application
|
||||||
Generated
+38
-39
@@ -15,7 +15,7 @@
|
|||||||
"pg": "^8.11.0"
|
"pg": "^8.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.10"
|
"nodemon": "^2.0.22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -764,16 +764,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/morgan": {
|
"node_modules/morgan": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
||||||
"integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==",
|
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"basic-auth": "~2.0.1",
|
"basic-auth": "~2.0.1",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "~2.0.0",
|
"depd": "~2.0.0",
|
||||||
"on-finished": "~2.3.0",
|
"on-finished": "~2.3.0",
|
||||||
"on-headers": "~1.0.2"
|
"on-headers": "~1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
@@ -807,19 +807,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "2.0.22",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
||||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.5.2",
|
"chokidar": "^3.5.2",
|
||||||
"debug": "^4",
|
"debug": "^3.2.7",
|
||||||
"ignore-by-default": "^1.0.1",
|
"ignore-by-default": "^1.0.1",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"pstree.remy": "^1.1.8",
|
"pstree.remy": "^1.1.8",
|
||||||
"semver": "^7.5.3",
|
"semver": "^5.7.1",
|
||||||
"simple-update-notifier": "^2.0.0",
|
"simple-update-notifier": "^1.0.7",
|
||||||
"supports-color": "^5.5.0",
|
"supports-color": "^5.5.0",
|
||||||
"touch": "^3.1.0",
|
"touch": "^3.1.0",
|
||||||
"undefsafe": "^2.0.5"
|
"undefsafe": "^2.0.5"
|
||||||
@@ -828,7 +828,7 @@
|
|||||||
"nodemon": "bin/nodemon.js"
|
"nodemon": "bin/nodemon.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=8.10.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -836,21 +836,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "3.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.1"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/ms": {
|
"node_modules/nodemon/node_modules/ms": {
|
||||||
@@ -904,9 +896,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-headers": {
|
"node_modules/on-headers": {
|
||||||
"version": "1.0.2",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
@@ -1167,16 +1159,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "5.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
@@ -1312,16 +1301,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "2.0.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
||||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.5.3"
|
"semver": "~7.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simple-update-notifier/node_modules/semver": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
"node_modules/split2": {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ global.pool = pool;
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw'],
|
origin: ['http://localhost:5175', 'http://localhost:5174', 'https://inventory.kent.pw', 'https://acot.site', 'https://tools.acherryontop.com'],
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Chat Server Database Configuration Template
|
||||||
|
# Copy this to your .env file and update the values for your new server
|
||||||
|
|
||||||
|
# Database Configuration for New Server
|
||||||
|
CHAT_DB_HOST=your-new-server-ip-or-hostname
|
||||||
|
CHAT_DB_PORT=5432
|
||||||
|
CHAT_DB_NAME=rocketchat_converted
|
||||||
|
CHAT_DB_USER=rocketchat_user
|
||||||
|
CHAT_DB_PASSWORD=your-secure-password
|
||||||
|
|
||||||
|
# Chat Server Port
|
||||||
|
CHAT_PORT=3014
|
||||||
|
|
||||||
|
# Example configuration:
|
||||||
|
# CHAT_DB_HOST=192.168.1.100
|
||||||
|
# CHAT_DB_PORT=5432
|
||||||
|
# CHAT_DB_NAME=rocketchat_converted
|
||||||
|
# CHAT_DB_USER=rocketchat_user
|
||||||
|
# CHAT_DB_PASSWORD=MySecureP@ssw0rd123
|
||||||
|
|
||||||
|
# Notes:
|
||||||
|
# - Replace 'your-new-server-ip-or-hostname' with actual server address
|
||||||
|
# - Use a strong password for CHAT_DB_PASSWORD
|
||||||
|
# - Ensure the new server allows connections from your application server
|
||||||
|
# - Update any firewall rules to allow PostgreSQL connections (port 5432)
|
||||||
|
# - Test connectivity before updating production configuration
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat Database Migration Verification Script
|
||||||
|
*
|
||||||
|
* This script verifies that the chat database migration was successful
|
||||||
|
* by comparing record counts and testing basic functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config({ path: '../.env' });
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.CHAT_DB_HOST || 'localhost',
|
||||||
|
user: process.env.CHAT_DB_USER || 'rocketchat_user',
|
||||||
|
password: process.env.CHAT_DB_PASSWORD,
|
||||||
|
database: process.env.CHAT_DB_NAME || 'rocketchat_converted',
|
||||||
|
port: process.env.CHAT_DB_PORT || 5432,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalStats = process.argv[2] ? JSON.parse(process.argv[2]) : null;
|
||||||
|
|
||||||
|
async function verifyMigration() {
|
||||||
|
console.log('🔍 Starting migration verification...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test basic connection
|
||||||
|
console.log('🔗 Testing database connection...');
|
||||||
|
const versionResult = await pool.query('SELECT version()');
|
||||||
|
console.log('✅ Database connection successful');
|
||||||
|
console.log(` PostgreSQL version: ${versionResult.rows[0].version.split(' ')[1]}\n`);
|
||||||
|
|
||||||
|
// Get table statistics
|
||||||
|
console.log('📊 Checking table statistics...');
|
||||||
|
const statsResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
tablename,
|
||||||
|
n_live_tup as row_count,
|
||||||
|
n_dead_tup as dead_rows,
|
||||||
|
schemaname
|
||||||
|
FROM pg_stat_user_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
ORDER BY n_live_tup DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (statsResult.rows.length === 0) {
|
||||||
|
console.log('❌ No tables found! Migration may have failed.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📋 Table Statistics:');
|
||||||
|
console.log(' Table Name | Row Count | Dead Rows');
|
||||||
|
console.log(' -------------------|-----------|----------');
|
||||||
|
|
||||||
|
let totalRows = 0;
|
||||||
|
const tableStats = {};
|
||||||
|
|
||||||
|
for (const row of statsResult.rows) {
|
||||||
|
const rowCount = parseInt(row.row_count) || 0;
|
||||||
|
const deadRows = parseInt(row.dead_rows) || 0;
|
||||||
|
totalRows += rowCount;
|
||||||
|
tableStats[row.tablename] = rowCount;
|
||||||
|
|
||||||
|
console.log(` ${row.tablename.padEnd(18)} | ${rowCount.toString().padStart(9)} | ${deadRows.toString().padStart(8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n Total rows across all tables: ${totalRows}\n`);
|
||||||
|
|
||||||
|
// Verify critical tables exist and have data
|
||||||
|
const criticalTables = ['users', 'message', 'room'];
|
||||||
|
console.log('🔑 Checking critical tables...');
|
||||||
|
|
||||||
|
for (const table of criticalTables) {
|
||||||
|
if (tableStats[table] > 0) {
|
||||||
|
console.log(`✅ ${table}: ${tableStats[table]} rows`);
|
||||||
|
} else if (tableStats[table] === 0) {
|
||||||
|
console.log(`⚠️ ${table}: table exists but is empty`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${table}: table not found`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test specific functionality
|
||||||
|
console.log('\n🧪 Testing specific functionality...');
|
||||||
|
|
||||||
|
// Test users table
|
||||||
|
const userTest = await pool.query(`
|
||||||
|
SELECT COUNT(*) as total_users,
|
||||||
|
COUNT(*) FILTER (WHERE active = true) as active_users,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'user') as regular_users
|
||||||
|
FROM users
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (userTest.rows[0]) {
|
||||||
|
const { total_users, active_users, regular_users } = userTest.rows[0];
|
||||||
|
console.log(`✅ Users: ${total_users} total, ${active_users} active, ${regular_users} regular users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test messages table
|
||||||
|
const messageTest = await pool.query(`
|
||||||
|
SELECT COUNT(*) as total_messages,
|
||||||
|
COUNT(DISTINCT rid) as unique_rooms,
|
||||||
|
MIN(ts) as oldest_message,
|
||||||
|
MAX(ts) as newest_message
|
||||||
|
FROM message
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (messageTest.rows[0]) {
|
||||||
|
const { total_messages, unique_rooms, oldest_message, newest_message } = messageTest.rows[0];
|
||||||
|
console.log(`✅ Messages: ${total_messages} total across ${unique_rooms} rooms`);
|
||||||
|
if (oldest_message && newest_message) {
|
||||||
|
console.log(` Date range: ${oldest_message.toISOString().split('T')[0]} to ${newest_message.toISOString().split('T')[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rooms table
|
||||||
|
const roomTest = await pool.query(`
|
||||||
|
SELECT COUNT(*) as total_rooms,
|
||||||
|
COUNT(*) FILTER (WHERE t = 'c') as channels,
|
||||||
|
COUNT(*) FILTER (WHERE t = 'p') as private_groups,
|
||||||
|
COUNT(*) FILTER (WHERE t = 'd') as direct_messages
|
||||||
|
FROM room
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (roomTest.rows[0]) {
|
||||||
|
const { total_rooms, channels, private_groups, direct_messages } = roomTest.rows[0];
|
||||||
|
console.log(`✅ Rooms: ${total_rooms} total (${channels} channels, ${private_groups} private, ${direct_messages} DMs)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test file uploads if table exists
|
||||||
|
if (tableStats.uploads > 0) {
|
||||||
|
const uploadTest = await pool.query(`
|
||||||
|
SELECT COUNT(*) as total_uploads,
|
||||||
|
COUNT(DISTINCT typegroup) as file_types,
|
||||||
|
pg_size_pretty(SUM(size)) as total_size
|
||||||
|
FROM uploads
|
||||||
|
WHERE size IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (uploadTest.rows[0]) {
|
||||||
|
const { total_uploads, file_types, total_size } = uploadTest.rows[0];
|
||||||
|
console.log(`✅ Uploads: ${total_uploads} files, ${file_types} types, ${total_size || 'unknown size'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test server health endpoint simulation
|
||||||
|
console.log('\n🏥 Testing application endpoints simulation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthTest = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM users WHERE active = true) as active_users,
|
||||||
|
(SELECT COUNT(*) FROM message) as total_messages,
|
||||||
|
(SELECT COUNT(*) FROM room) as total_rooms
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (healthTest.rows[0]) {
|
||||||
|
const stats = healthTest.rows[0];
|
||||||
|
console.log('✅ Health check simulation passed');
|
||||||
|
console.log(` Active users: ${stats.active_users}`);
|
||||||
|
console.log(` Total messages: ${stats.total_messages}`);
|
||||||
|
console.log(` Total rooms: ${stats.total_rooms}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`⚠️ Health check simulation failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check indexes
|
||||||
|
console.log('\n📇 Checking database indexes...');
|
||||||
|
const indexResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
indexname,
|
||||||
|
indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
ORDER BY tablename, indexname
|
||||||
|
`);
|
||||||
|
|
||||||
|
const indexesByTable = {};
|
||||||
|
for (const idx of indexResult.rows) {
|
||||||
|
if (!indexesByTable[idx.tablename]) {
|
||||||
|
indexesByTable[idx.tablename] = [];
|
||||||
|
}
|
||||||
|
indexesByTable[idx.tablename].push(idx.indexname);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [table, indexes] of Object.entries(indexesByTable)) {
|
||||||
|
console.log(` ${table}: ${indexes.length} indexes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Migration verification completed successfully!');
|
||||||
|
console.log('\n✅ Summary:');
|
||||||
|
console.log(` - Database connection: Working`);
|
||||||
|
console.log(` - Tables created: ${statsResult.rows.length}`);
|
||||||
|
console.log(` - Total data rows: ${totalRows}`);
|
||||||
|
console.log(` - Critical tables: All present`);
|
||||||
|
console.log(` - Indexes: ${indexResult.rows.length} total`);
|
||||||
|
|
||||||
|
console.log('\n🚀 Next steps:');
|
||||||
|
console.log(' 1. Update your application configuration');
|
||||||
|
console.log(' 2. Start your chat server');
|
||||||
|
console.log(' 3. Test chat functionality in the browser');
|
||||||
|
console.log(' 4. Monitor logs for any issues');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration verification failed:', error.message);
|
||||||
|
console.error('\n🔧 Troubleshooting steps:');
|
||||||
|
console.error(' 1. Check database connection settings');
|
||||||
|
console.error(' 2. Verify database and user exist');
|
||||||
|
console.error(' 3. Check PostgreSQL logs');
|
||||||
|
console.error(' 4. Ensure import completed without errors');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run verification
|
||||||
|
if (require.main === module) {
|
||||||
|
verifyMigration().then(success => {
|
||||||
|
process.exit(success ? 0 : 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { verifyMigration };
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# dashboard-server .env template (Phase 4)
|
||||||
|
#
|
||||||
|
# The merged dashboard-server reads /var/www/inventory/.env FIRST (provides
|
||||||
|
# JWT_SECRET, DB_*, REDIS_*) and then layers this .env on top for vendor keys.
|
||||||
|
# Shared/security-critical vars stay in /var/www/inventory/.env so they aren't
|
||||||
|
# duplicated; vendor keys live here.
|
||||||
|
#
|
||||||
|
# Copy to .env and populate. Do NOT commit the populated file.
|
||||||
|
|
||||||
|
# Port the merged service listens on
|
||||||
|
DASHBOARD_PORT=3015
|
||||||
|
|
||||||
|
# Klaviyo (replaces klaviyo-server/.env)
|
||||||
|
KLAVIYO_API_KEY=
|
||||||
|
KLAVIYO_API_REVISION=2024-02-15
|
||||||
|
KLAVIYO_API_URL=https://a.klaviyo.com/api
|
||||||
|
|
||||||
|
# Meta / Facebook Ads (replaces meta-server/.env)
|
||||||
|
META_ACCESS_TOKEN=
|
||||||
|
META_AD_ACCOUNT_ID=
|
||||||
|
META_API_VERSION=v21.0
|
||||||
|
|
||||||
|
# Google Analytics (replaces google-server/.env)
|
||||||
|
GA_PROPERTY_ID=
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS_JSON=
|
||||||
|
|
||||||
|
# Typeform (replaces typeform-server/.env)
|
||||||
|
TYPEFORM_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# Vendors share the inventory REDIS_URL or REDIS_HOST/PORT/USERNAME/PASSWORD
|
||||||
|
# from the parent .env. Do NOT redeclare here unless you need a vendor-only
|
||||||
|
# override (rare; would need to fork shared/db/redis.js too).
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# ACOT Server
|
||||||
|
|
||||||
|
This server replaces the Klaviyo integration with direct database queries to the production MySQL database via SSH tunnel. It provides seamless API compatibility for all frontend components without requiring any frontend changes.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Environment Variables**: Copy `.env.example` to `.env` and configure:
|
||||||
|
```
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=your_db_user
|
||||||
|
DB_PASSWORD=your_db_password
|
||||||
|
DB_NAME=your_db_name
|
||||||
|
PORT=3007
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **SSH Tunnel**: Ensure your SSH tunnel to the production database is running on localhost:3306.
|
||||||
|
|
||||||
|
3. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start Server**:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All endpoints provide exact API compatibility with the previous Klaviyo implementation:
|
||||||
|
|
||||||
|
### Main Statistics
|
||||||
|
- `GET /api/acot/events/stats` - Complete statistics dashboard data
|
||||||
|
- Query params: `timeRange` (today, yesterday, thisWeek, lastWeek, thisMonth, lastMonth, last7days, last30days, last90days) or `startDate`/`endDate` for custom ranges
|
||||||
|
- Returns: Revenue, orders, AOV, shipping data, order types, brands/categories, refunds, cancellations, best day, peak hour, order ranges, period progress, projections
|
||||||
|
|
||||||
|
### Daily Details
|
||||||
|
- `GET /api/acot/events/stats/details` - Daily breakdown with previous period comparisons
|
||||||
|
- Query params: `timeRange`, `metric` (revenue, orders, average_order, etc.), `daily=true`
|
||||||
|
- Returns: Array of daily data points with trend comparisons
|
||||||
|
|
||||||
|
### Products
|
||||||
|
- `GET /api/acot/events/products` - Top products with sales data
|
||||||
|
- Query params: `timeRange`
|
||||||
|
- Returns: Product list with images, sales quantities, revenue, and order counts
|
||||||
|
|
||||||
|
### Projections
|
||||||
|
- `GET /api/acot/events/projection` - Smart revenue projections for incomplete periods
|
||||||
|
- Query params: `timeRange`
|
||||||
|
- Returns: Projected revenue with confidence levels based on historical patterns
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
- `GET /api/acot/test` - Server health and database connectivity test
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The server queries the following main tables:
|
||||||
|
|
||||||
|
### Orders (`_order`)
|
||||||
|
- **Key fields**: `order_id`, `date_placed`, `summary_total`, `order_status`, `ship_method_selected`, `stats_waiting_preorder`
|
||||||
|
- **Valid orders**: `order_status > 15`
|
||||||
|
- **Cancelled orders**: `order_status = 15`
|
||||||
|
- **Shipped orders**: `order_status IN (100, 92)`
|
||||||
|
- **Pre-orders**: `stats_waiting_preorder > 0`
|
||||||
|
- **Local pickup**: `ship_method_selected = 'localpickup'`
|
||||||
|
- **On-hold orders**: `ship_method_selected = 'holdit'`
|
||||||
|
|
||||||
|
### Order Items (`order_items`)
|
||||||
|
- **Fields**: `order_id`, `prod_pid`, `qty_ordered`, `prod_price`
|
||||||
|
- **Purpose**: Links orders to products for detailed analysis
|
||||||
|
|
||||||
|
### Products (`products`)
|
||||||
|
- **Fields**: `pid`, `description` (product name), `company`
|
||||||
|
- **Purpose**: Product information and brand data
|
||||||
|
|
||||||
|
### Product Images (`product_images`)
|
||||||
|
- **Fields**: `pid`, `iid`, `order` (priority)
|
||||||
|
- **Primary image**: `order = 255` (highest priority)
|
||||||
|
- **Image URL generation**: `https://sbing.com/i/products/0000/{prefix}/{pid}-{type}-{iid}.jpg`
|
||||||
|
|
||||||
|
### Payments (`order_payment`)
|
||||||
|
- **Refunds**: `payment_amount < 0`
|
||||||
|
- **Purpose**: Track refund amounts and counts
|
||||||
|
|
||||||
|
## Business Logic
|
||||||
|
|
||||||
|
### Time Handling
|
||||||
|
- **Timezone**: All calculations in UTC-5 (Eastern Time)
|
||||||
|
- **Business Day**: 1 AM - 12:59 AM Eastern (25-hour business day)
|
||||||
|
- **Format**: MySQL DATETIME format (YYYY-MM-DD HH:MM:SS)
|
||||||
|
- **Period Boundaries**: Calculated using `timeUtils.js` for consistent time range handling
|
||||||
|
|
||||||
|
### Order Processing
|
||||||
|
- **Revenue Calculation**: Only includes orders with `order_status > 15`
|
||||||
|
- **Order Types**:
|
||||||
|
- Pre-orders: `stats_waiting_preorder > 0`
|
||||||
|
- Local pickup: `ship_method_selected = 'localpickup'`
|
||||||
|
- On-hold: `ship_method_selected = 'holdit'`
|
||||||
|
- **Shipping Methods**: Mapped to friendly names (e.g., `usps_ground_advantage` → "USPS Ground Advantage")
|
||||||
|
|
||||||
|
### Projections
|
||||||
|
- **Period Progress**: Calculated based on current time within the selected period
|
||||||
|
- **Simple Projection**: Linear extrapolation based on current progress
|
||||||
|
- **Smart Projection**: Uses historical data patterns for more accurate forecasting
|
||||||
|
- **Confidence Levels**: Based on data consistency and historical accuracy
|
||||||
|
|
||||||
|
### Image URL Generation
|
||||||
|
- **Pattern**: `https://sbing.com/i/products/0000/{prefix}/{pid}-{type}-{iid}.jpg`
|
||||||
|
- **Prefix**: First 2 digits of product ID
|
||||||
|
- **Type**: "main" for primary images
|
||||||
|
- **Fallback**: Uses primary image (order=255) when available
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Service Layer (`services/acotService.js`)
|
||||||
|
- **Purpose**: Replaces direct Klaviyo API calls with acot-server calls
|
||||||
|
- **Methods**: `getStats()`, `getStatsDetails()`, `getProducts()`, `getProjection()`
|
||||||
|
- **Logging**: Axios interceptors for request/response logging
|
||||||
|
- **Environment**: Automatic URL handling (proxy in dev, direct in production)
|
||||||
|
|
||||||
|
### Component Updates
|
||||||
|
All 5 main components updated to use `acotService`:
|
||||||
|
- **StatCards.jsx**: Main dashboard statistics
|
||||||
|
- **MiniStatCards.jsx**: Compact statistics view
|
||||||
|
- **SalesChart.jsx**: Revenue and order trends
|
||||||
|
- **MiniSalesChart.jsx**: Compact chart view
|
||||||
|
- **ProductGrid.jsx**: Top products table
|
||||||
|
|
||||||
|
### Proxy Configuration (`vite.config.js`)
|
||||||
|
```javascript
|
||||||
|
'/api/acot': {
|
||||||
|
target: 'http://localhost:3007',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Complete Business Intelligence
|
||||||
|
- **Revenue Analytics**: Total revenue, trends, projections
|
||||||
|
- **Order Analysis**: Counts, types, status tracking
|
||||||
|
- **Product Performance**: Top sellers, revenue contribution
|
||||||
|
- **Shipping Intelligence**: Methods, locations, distribution
|
||||||
|
- **Customer Insights**: Order value ranges, patterns
|
||||||
|
- **Operational Metrics**: Refunds, cancellations, peak hours
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
- **Connection Pooling**: Efficient database connection management
|
||||||
|
- **Query Optimization**: Indexed queries with proper WHERE clauses
|
||||||
|
- **Caching Strategy**: Frontend caching for detail views
|
||||||
|
- **Batch Processing**: Efficient data aggregation
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Database Connectivity**: Graceful handling of connection issues
|
||||||
|
- **Query Failures**: Detailed error logging and user-friendly messages
|
||||||
|
- **Data Validation**: Input sanitization and validation
|
||||||
|
- **Fallback Mechanisms**: Default values for missing data
|
||||||
|
|
||||||
|
## Simplified Elements
|
||||||
|
|
||||||
|
Due to database complexity, some features are simplified:
|
||||||
|
- **Brands**: Shows "Various Brands" (companies table structure complex)
|
||||||
|
- **Categories**: Shows "General" (category relationships complex)
|
||||||
|
|
||||||
|
These can be enhanced in future iterations with proper category mapping.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Test the server functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:3007/api/acot/test
|
||||||
|
|
||||||
|
# Today's stats
|
||||||
|
curl http://localhost:3007/api/acot/events/stats?timeRange=today
|
||||||
|
|
||||||
|
# Last 30 days with details
|
||||||
|
curl http://localhost:3007/api/acot/events/stats/details?timeRange=last30days&daily=true
|
||||||
|
|
||||||
|
# Top products
|
||||||
|
curl http://localhost:3007/api/acot/events/products?timeRange=thisWeek
|
||||||
|
|
||||||
|
# Revenue projection
|
||||||
|
curl http://localhost:3007/api/acot/events/projection?timeRange=today
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
- **No Frontend Changes**: Complete drop-in replacement for Klaviyo
|
||||||
|
- **API Compatibility**: Maintains exact response structure
|
||||||
|
- **Business Logic**: Implements all complex e-commerce calculations
|
||||||
|
- **Scalability**: Designed for production workloads
|
||||||
|
- **Maintainability**: Well-documented code with clear separation of concerns
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Enhanced category and brand mapping
|
||||||
|
- Real-time notifications for significant events
|
||||||
|
- Advanced analytics and forecasting
|
||||||
|
- Customer segmentation analysis
|
||||||
|
- Inventory integration
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
const { Client } = require('ssh2');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Connection pool configuration
|
||||||
|
const connectionPool = {
|
||||||
|
connections: [],
|
||||||
|
maxConnections: 20,
|
||||||
|
currentConnections: 0,
|
||||||
|
pendingRequests: [],
|
||||||
|
// Cache for query results (key: query string, value: {data, timestamp})
|
||||||
|
queryCache: new Map(),
|
||||||
|
// Cache duration for different query types in milliseconds
|
||||||
|
cacheDuration: {
|
||||||
|
'stats': 60 * 1000, // 1 minute for stats
|
||||||
|
'products': 5 * 60 * 1000, // 5 minutes for products
|
||||||
|
'orders': 60 * 1000, // 1 minute for orders
|
||||||
|
'default': 60 * 1000 // 1 minute default
|
||||||
|
},
|
||||||
|
// Circuit breaker state
|
||||||
|
circuitBreaker: {
|
||||||
|
failures: 0,
|
||||||
|
lastFailure: 0,
|
||||||
|
isOpen: false,
|
||||||
|
threshold: 5,
|
||||||
|
timeout: 30000 // 30 seconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database connection from the pool
|
||||||
|
* @returns {Promise<{connection: object, release: function}>} The database connection and release function
|
||||||
|
*/
|
||||||
|
async function getDbConnection() {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
// Check circuit breaker
|
||||||
|
const now = Date.now();
|
||||||
|
if (connectionPool.circuitBreaker.isOpen) {
|
||||||
|
if (now - connectionPool.circuitBreaker.lastFailure > connectionPool.circuitBreaker.timeout) {
|
||||||
|
// Reset circuit breaker
|
||||||
|
connectionPool.circuitBreaker.isOpen = false;
|
||||||
|
connectionPool.circuitBreaker.failures = 0;
|
||||||
|
console.log('Circuit breaker reset');
|
||||||
|
} else {
|
||||||
|
reject(new Error('Circuit breaker is open - too many connection failures'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's an available connection in the pool
|
||||||
|
if (connectionPool.connections.length > 0) {
|
||||||
|
const conn = connectionPool.connections.pop();
|
||||||
|
console.log(`Using pooled connection. Pool size: ${connectionPool.connections.length}`);
|
||||||
|
resolve({
|
||||||
|
connection: conn.connection,
|
||||||
|
release: () => releaseConnection(conn)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't reached max connections, create a new one
|
||||||
|
if (connectionPool.currentConnections < connectionPool.maxConnections) {
|
||||||
|
try {
|
||||||
|
console.log(`Creating new connection. Current: ${connectionPool.currentConnections}/${connectionPool.maxConnections}`);
|
||||||
|
connectionPool.currentConnections++;
|
||||||
|
|
||||||
|
const tunnel = await setupSshTunnel();
|
||||||
|
const { ssh, stream, dbConfig } = tunnel;
|
||||||
|
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
...dbConfig,
|
||||||
|
stream
|
||||||
|
});
|
||||||
|
|
||||||
|
const conn = { ssh, connection, inUse: true, created: Date.now() };
|
||||||
|
|
||||||
|
console.log('Database connection established');
|
||||||
|
|
||||||
|
// Reset circuit breaker on successful connection
|
||||||
|
if (connectionPool.circuitBreaker.failures > 0) {
|
||||||
|
connectionPool.circuitBreaker.failures = 0;
|
||||||
|
connectionPool.circuitBreaker.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
connection: conn.connection,
|
||||||
|
release: () => releaseConnection(conn)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
connectionPool.currentConnections--;
|
||||||
|
|
||||||
|
// Track circuit breaker failures
|
||||||
|
connectionPool.circuitBreaker.failures++;
|
||||||
|
connectionPool.circuitBreaker.lastFailure = Date.now();
|
||||||
|
|
||||||
|
if (connectionPool.circuitBreaker.failures >= connectionPool.circuitBreaker.threshold) {
|
||||||
|
connectionPool.circuitBreaker.isOpen = true;
|
||||||
|
console.log(`Circuit breaker opened after ${connectionPool.circuitBreaker.failures} failures`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool is full, queue the request with timeout
|
||||||
|
console.log('Connection pool full, queuing request...');
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// Remove from queue if still there
|
||||||
|
const index = connectionPool.pendingRequests.findIndex(req => req.resolve === resolve);
|
||||||
|
if (index !== -1) {
|
||||||
|
connectionPool.pendingRequests.splice(index, 1);
|
||||||
|
reject(new Error('Connection pool queue timeout after 15 seconds'));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
connectionPool.pendingRequests.push({
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a connection back to the pool
|
||||||
|
*/
|
||||||
|
function releaseConnection(conn) {
|
||||||
|
conn.inUse = false;
|
||||||
|
|
||||||
|
// Check if there are pending requests
|
||||||
|
if (connectionPool.pendingRequests.length > 0) {
|
||||||
|
const { resolve, timeoutId } = connectionPool.pendingRequests.shift();
|
||||||
|
|
||||||
|
// Clear the timeout since we're serving the request
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.inUse = true;
|
||||||
|
console.log(`Serving queued request. Queue length: ${connectionPool.pendingRequests.length}`);
|
||||||
|
resolve({
|
||||||
|
connection: conn.connection,
|
||||||
|
release: () => releaseConnection(conn)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Return to pool
|
||||||
|
connectionPool.connections.push(conn);
|
||||||
|
console.log(`Connection returned to pool. Pool size: ${connectionPool.connections.length}, Active: ${connectionPool.currentConnections}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached query results or execute query if not cached
|
||||||
|
* @param {string} cacheKey - Unique key to identify the query
|
||||||
|
* @param {string} queryType - Type of query (stats, products, orders, etc.)
|
||||||
|
* @param {Function} queryFn - Function to execute if cache miss
|
||||||
|
* @returns {Promise<any>} The query result
|
||||||
|
*/
|
||||||
|
async function getCachedQuery(cacheKey, queryType, queryFn) {
|
||||||
|
// Get cache duration based on query type
|
||||||
|
const cacheDuration = connectionPool.cacheDuration[queryType] || connectionPool.cacheDuration.default;
|
||||||
|
|
||||||
|
// Check if we have a valid cached result
|
||||||
|
const cachedResult = connectionPool.queryCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) {
|
||||||
|
console.log(`Cache hit for ${queryType} query: ${cacheKey}`);
|
||||||
|
return cachedResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid cache found, execute the query
|
||||||
|
console.log(`Cache miss for ${queryType} query: ${cacheKey}`);
|
||||||
|
const result = await queryFn();
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
connectionPool.queryCache.set(cacheKey, {
|
||||||
|
data: result,
|
||||||
|
timestamp: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup SSH tunnel to production database
|
||||||
|
* @private - Should only be used by getDbConnection
|
||||||
|
* @returns {Promise<{ssh: object, stream: object, dbConfig: object}>}
|
||||||
|
*/
|
||||||
|
async function setupSshTunnel() {
|
||||||
|
const sshConfig = {
|
||||||
|
host: process.env.PROD_SSH_HOST,
|
||||||
|
port: process.env.PROD_SSH_PORT || 22,
|
||||||
|
username: process.env.PROD_SSH_USER,
|
||||||
|
privateKey: process.env.PROD_SSH_KEY_PATH
|
||||||
|
? fs.readFileSync(process.env.PROD_SSH_KEY_PATH)
|
||||||
|
: undefined,
|
||||||
|
compress: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.PROD_DB_HOST || 'localhost',
|
||||||
|
user: process.env.PROD_DB_USER,
|
||||||
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
|
database: process.env.PROD_DB_NAME,
|
||||||
|
port: process.env.PROD_DB_PORT || 3306,
|
||||||
|
timezone: 'Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ssh = new Client();
|
||||||
|
|
||||||
|
ssh.on('error', (err) => {
|
||||||
|
console.error('SSH connection error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ssh.on('ready', () => {
|
||||||
|
ssh.forwardOut(
|
||||||
|
'127.0.0.1',
|
||||||
|
0,
|
||||||
|
dbConfig.host,
|
||||||
|
dbConfig.port,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve({ ssh, stream, dbConfig });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).connect(sshConfig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached query results
|
||||||
|
* @param {string} [cacheKey] - Specific cache key to clear (clears all if not provided)
|
||||||
|
*/
|
||||||
|
function clearQueryCache(cacheKey) {
|
||||||
|
if (cacheKey) {
|
||||||
|
connectionPool.queryCache.delete(cacheKey);
|
||||||
|
console.log(`Cleared cache for key: ${cacheKey}`);
|
||||||
|
} else {
|
||||||
|
connectionPool.queryCache.clear();
|
||||||
|
console.log('Cleared all query cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force close all active connections
|
||||||
|
* Useful for server shutdown or manual connection reset
|
||||||
|
*/
|
||||||
|
async function closeAllConnections() {
|
||||||
|
// Close all pooled connections
|
||||||
|
for (const conn of connectionPool.connections) {
|
||||||
|
try {
|
||||||
|
await conn.connection.end();
|
||||||
|
conn.ssh.end();
|
||||||
|
console.log('Closed pooled connection');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing pooled connection:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset pool state
|
||||||
|
connectionPool.connections = [];
|
||||||
|
connectionPool.currentConnections = 0;
|
||||||
|
connectionPool.pendingRequests = [];
|
||||||
|
connectionPool.queryCache.clear();
|
||||||
|
|
||||||
|
console.log('All connections closed and pool reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection pool status for debugging
|
||||||
|
*/
|
||||||
|
function getPoolStatus() {
|
||||||
|
return {
|
||||||
|
poolSize: connectionPool.connections.length,
|
||||||
|
activeConnections: connectionPool.currentConnections,
|
||||||
|
maxConnections: connectionPool.maxConnections,
|
||||||
|
pendingRequests: connectionPool.pendingRequests.length,
|
||||||
|
cacheSize: connectionPool.queryCache.size,
|
||||||
|
queuedRequests: connectionPool.pendingRequests.map(req => ({
|
||||||
|
waitTime: Date.now() - req.timestamp,
|
||||||
|
hasTimeout: !!req.timeoutId
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDbConnection,
|
||||||
|
getCachedQuery,
|
||||||
|
clearQueryCache,
|
||||||
|
closeAllConnections,
|
||||||
|
getPoolStatus
|
||||||
|
};
|
||||||
+1553
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "acot-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A Cherry On Top production database server",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"ssh2": "^1.14.0",
|
||||||
|
"mysql2": "^3.6.5",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"luxon": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// Customer lookup for the phone app (acot-phone-server).
|
||||||
|
//
|
||||||
|
// All queries hit the MySQL `sg` database via the shared SSH-tunneled pool in
|
||||||
|
// db/connection.js. The stats/orders logic mirrors the freescout
|
||||||
|
// ACOTCustomerData module so both apps display the same numbers for a given
|
||||||
|
// customer — the difference is that we key by phone, not email.
|
||||||
|
//
|
||||||
|
// NOTE: `users.phone` is not yet indexed in production. Admin will add
|
||||||
|
// `idx_phone (phone)` — queries here assume that exists for acceptable latency.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDbConnection, getCachedQuery } = require('../db/connection');
|
||||||
|
const { requirePhoneApiKey } = require('../utils/phoneAuth');
|
||||||
|
|
||||||
|
// Order status labels mirror ACOTCustomerDataServiceProvider.php.
|
||||||
|
const ORDER_STATUS_LABEL = {
|
||||||
|
0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined',
|
||||||
|
20: 'Placed', 22: 'Placed (Incomplete)', 40: 'Awaiting Payment',
|
||||||
|
45: 'Payment Pending', 50: 'Awaiting Products', 55: 'Shipping Later',
|
||||||
|
56: 'Shipping Together', 60: 'Ready', 61: 'Flagged', 62: 'Fix Before Pick',
|
||||||
|
65: 'Manual Picking', 67: 'Remote Send', 70: 'In PT', 80: 'Picked',
|
||||||
|
90: 'Awaiting Shipment', 91: 'Remote Wait', 92: 'Awaiting Pickup',
|
||||||
|
93: 'Fix Before Ship', 95: 'Shipped (Confirmed)', 100: 'Shipped',
|
||||||
|
};
|
||||||
|
const ORDER_STATUS_SHORT = {
|
||||||
|
0: 'Created', 10: 'Incomplete', 15: 'Cancelled', 16: 'Combined',
|
||||||
|
20: 'Placed', 22: 'Plcd Incomp', 40: 'Await Payment', 45: 'Pymt Pending',
|
||||||
|
50: 'Await Products', 55: 'Ship Later', 56: 'Ship Togethr', 60: 'Ready',
|
||||||
|
61: 'Flagged', 62: 'Fix Bfr Pick', 65: 'Manual Pick', 67: 'Remote Send',
|
||||||
|
70: 'In PT', 80: 'Picked', 90: 'Await Ship', 91: 'Remote Wait',
|
||||||
|
92: 'Await Pickup', 93: 'Fix Bfr Ship', 95: 'Shpd Confirm', 100: 'Shipped',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(s) { return ORDER_STATUS_LABEL[s] ?? `Unknown (${s})`; }
|
||||||
|
function statusShort(s) { return ORDER_STATUS_SHORT[s] ?? `Unknown (${s})`; }
|
||||||
|
|
||||||
|
// SIP trunks and historical CRM imports all disagree on phone format. Rather
|
||||||
|
// than normalize everything upstream, we search across the most common
|
||||||
|
// variations for US/Canada numbers. Falls through to the raw input for
|
||||||
|
// international numbers we can't safely reformat.
|
||||||
|
function phoneVariations(input) {
|
||||||
|
const raw = String(input || '').trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const out = new Set([raw, digits]);
|
||||||
|
if (digits.length === 10) {
|
||||||
|
out.add(`+1${digits}`);
|
||||||
|
out.add(`1${digits}`);
|
||||||
|
} else if (digits.length === 11 && digits.startsWith('1')) {
|
||||||
|
out.add(`+${digits}`);
|
||||||
|
out.add(digits.slice(1)); // 10-digit form
|
||||||
|
out.add(`+1${digits.slice(1)}`);
|
||||||
|
}
|
||||||
|
return Array.from(out).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackingLink(method, tracking) {
|
||||||
|
if (!tracking) return '';
|
||||||
|
if (typeof method === 'string') {
|
||||||
|
if (method.startsWith('usps_') || method === 'fedex_smartpost') {
|
||||||
|
return `https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=${tracking}`;
|
||||||
|
}
|
||||||
|
if (method.startsWith('fedex_')) {
|
||||||
|
return `https://www.fedex.com/fedextrack/?trknbr=${tracking}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches ACOTCustomerDataServiceProvider::imageUrl — sbing.com/i/products/<dir1>/<dir2>/<pid>-t-<iid>.jpg
|
||||||
|
function imageUrl(pid, iid = 1) {
|
||||||
|
const padded = String(pid).padStart(10, '0');
|
||||||
|
const dir1 = padded.slice(0, 4);
|
||||||
|
const dir2 = padded.slice(4, 7);
|
||||||
|
return `https://sbing.com/i/products/${dir1}/${dir2}/${pid}-t-${iid}.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(requirePhoneApiKey);
|
||||||
|
|
||||||
|
// ── GET /by-phone ──────────────────────────────────────────────────────────
|
||||||
|
// Returns top-line customer info for the incoming-call overlay.
|
||||||
|
router.get('/by-phone', async (req, res) => {
|
||||||
|
const phone = String(req.query.phone || '').trim();
|
||||||
|
if (!phone) return res.status(400).json({ success: false, error: 'phone required' });
|
||||||
|
|
||||||
|
const variations = phoneVariations(phone);
|
||||||
|
if (variations.length === 0) return res.json({ success: true, customer: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getCachedQuery(
|
||||||
|
`customer-by-phone:${variations.join('|')}`,
|
||||||
|
'default',
|
||||||
|
async () => {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
try {
|
||||||
|
const placeholders = variations.map(() => '?').join(',');
|
||||||
|
// Tie-break by highest LTV per user instructions: subquery computes LTV
|
||||||
|
// for every matching user, then we pick the biggest.
|
||||||
|
const [users] = await connection.execute(
|
||||||
|
`SELECT u.cid, u.uid, u.firstname, u.lastname, u.email, u.phone, u.points,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(summary_total)
|
||||||
|
FROM _order
|
||||||
|
WHERE order_cid = u.cid AND order_status >= 50
|
||||||
|
), 0) AS lifetime_value,
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM _order
|
||||||
|
WHERE order_cid = u.cid AND order_status >= 20
|
||||||
|
), 0) AS num_orders,
|
||||||
|
(
|
||||||
|
SELECT AVG(summary_total)
|
||||||
|
FROM _order
|
||||||
|
WHERE order_cid = u.cid AND order_status >= 20
|
||||||
|
) AS avg_order
|
||||||
|
FROM users u
|
||||||
|
WHERE u.phone IN (${placeholders})
|
||||||
|
ORDER BY lifetime_value DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
variations
|
||||||
|
);
|
||||||
|
return users[0] ?? null;
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return res.json({ success: true, customer: null });
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
customer: {
|
||||||
|
cid: Number(data.cid),
|
||||||
|
uid: data.uid,
|
||||||
|
firstName: data.firstname || null,
|
||||||
|
lastName: data.lastname || null,
|
||||||
|
email: data.email || null,
|
||||||
|
phone: data.phone,
|
||||||
|
points: Number(data.points) || 0,
|
||||||
|
lifetimeValue: Number(data.lifetime_value) || 0,
|
||||||
|
orderCount: Number(data.num_orders) || 0,
|
||||||
|
avgOrderValue: data.avg_order != null ? Number(data.avg_order) : 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('customers/by-phone failed:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'query_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /search ────────────────────────────────────────────────────────────
|
||||||
|
// Name search for the dialer. Accepts a free-text query; splits on whitespace.
|
||||||
|
// - 1 token: LIKE against firstname OR lastname (prefix).
|
||||||
|
// - 2+ tokens: firstname LIKE A% AND lastname LIKE B% (order-sensitive on purpose).
|
||||||
|
router.get('/search', async (req, res) => {
|
||||||
|
const q = String(req.query.q || '').trim();
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 25);
|
||||||
|
if (q.length < 2) return res.json({ success: true, results: [] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getCachedQuery(
|
||||||
|
`customer-search:${q}:${limit}`,
|
||||||
|
'default',
|
||||||
|
async () => {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
try {
|
||||||
|
const tokens = q.split(/\s+/).filter(Boolean);
|
||||||
|
let sql;
|
||||||
|
let params;
|
||||||
|
if (tokens.length === 1) {
|
||||||
|
const pattern = `${tokens[0]}%`;
|
||||||
|
sql = `SELECT cid, firstname, lastname, email, phone
|
||||||
|
FROM users
|
||||||
|
WHERE (firstname LIKE ? OR lastname LIKE ?)
|
||||||
|
AND phone <> ''
|
||||||
|
ORDER BY lastname, firstname
|
||||||
|
LIMIT ?`;
|
||||||
|
params = [pattern, pattern, limit];
|
||||||
|
} else {
|
||||||
|
const firstPat = `${tokens[0]}%`;
|
||||||
|
const lastPat = `${tokens.slice(1).join(' ')}%`;
|
||||||
|
sql = `SELECT cid, firstname, lastname, email, phone
|
||||||
|
FROM users
|
||||||
|
WHERE firstname LIKE ? AND lastname LIKE ?
|
||||||
|
AND phone <> ''
|
||||||
|
ORDER BY lastname, firstname
|
||||||
|
LIMIT ?`;
|
||||||
|
params = [firstPat, lastPat, limit];
|
||||||
|
}
|
||||||
|
const [rows] = await connection.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
results: data.map((r) => ({
|
||||||
|
cid: Number(r.cid),
|
||||||
|
firstName: r.firstname || null,
|
||||||
|
lastName: r.lastname || null,
|
||||||
|
email: r.email || null,
|
||||||
|
phone: r.phone,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('customers/search failed:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'query_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /:cid/orders ───────────────────────────────────────────────────────
|
||||||
|
// Recent orders for the active-call screen — mirrors the freescout sidebar.
|
||||||
|
router.get('/:cid/orders', async (req, res) => {
|
||||||
|
const cid = Number(req.params.cid);
|
||||||
|
if (!Number.isFinite(cid) || cid <= 0) {
|
||||||
|
return res.status(400).json({ success: false, error: 'bad_cid' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getCachedQuery(
|
||||||
|
`customer-orders:${cid}`,
|
||||||
|
'orders',
|
||||||
|
async () => {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
try {
|
||||||
|
// MySQL-safe equivalent of the Laravel query in the freescout module.
|
||||||
|
// Active = placed OR shipped within the last 3 months.
|
||||||
|
const [ordersRaw] = await connection.execute(
|
||||||
|
`SELECT order_id, order_status, order_type, summary_total,
|
||||||
|
date_placed, ship_method_type, ship_method_tracking,
|
||||||
|
CASE
|
||||||
|
WHEN (order_status BETWEEN 20 AND 92
|
||||||
|
OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH))
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END AS _is_active
|
||||||
|
FROM _order
|
||||||
|
WHERE order_cid = ?
|
||||||
|
AND (order_status >= 20
|
||||||
|
OR date_shipped > DATE_SUB(NOW(), INTERVAL 3 MONTH))
|
||||||
|
ORDER BY _is_active DESC, date_placed DESC`,
|
||||||
|
[cid]
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = ordersRaw.filter((o) => o._is_active === 1);
|
||||||
|
const inactive = ordersRaw.filter((o) => o._is_active === 0);
|
||||||
|
const orders = active.concat(inactive.slice(0, Math.max(0, 10 - active.length)));
|
||||||
|
|
||||||
|
if (orders.length === 0) return [];
|
||||||
|
|
||||||
|
const orderIds = orders.map((o) => o.order_id);
|
||||||
|
const idPlaceholders = orderIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
const [items] = await connection.execute(
|
||||||
|
`SELECT order_id, prod_pid, prod_itemnumber, prod_description, prod_price, qty_ordered
|
||||||
|
FROM order_items
|
||||||
|
WHERE order_id IN (${idPlaceholders})`,
|
||||||
|
orderIds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Main-image lookup: per-pid highest \`order\` at type=3 (matches the
|
||||||
|
// freescout module's raw SQL).
|
||||||
|
const pids = [...new Set(items.map((i) => Number(i.prod_pid)).filter(Boolean))];
|
||||||
|
const mainImagesByPid = new Map();
|
||||||
|
if (pids.length > 0) {
|
||||||
|
const pidList = pids.join(',');
|
||||||
|
const [imgRows] = await connection.execute(
|
||||||
|
`SELECT pi.pid, pi.iid
|
||||||
|
FROM product_images pi
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT pid, MAX(\`order\`) AS max_order
|
||||||
|
FROM product_images
|
||||||
|
WHERE pid IN (${pidList}) AND type = 3
|
||||||
|
GROUP BY pid
|
||||||
|
) pm ON pi.pid = pm.pid AND pi.\`order\` = pm.max_order AND pi.type = 3`
|
||||||
|
);
|
||||||
|
for (const r of imgRows) mainImagesByPid.set(Number(r.pid), Number(r.iid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsByOrder = new Map();
|
||||||
|
for (const it of items) {
|
||||||
|
const oid = Number(it.order_id);
|
||||||
|
if (!itemsByOrder.has(oid)) itemsByOrder.set(oid, []);
|
||||||
|
const iid = mainImagesByPid.get(Number(it.prod_pid)) ?? 1;
|
||||||
|
itemsByOrder.get(oid).push({
|
||||||
|
pid: Number(it.prod_pid),
|
||||||
|
sku: it.prod_itemnumber || null,
|
||||||
|
name: it.prod_description || null,
|
||||||
|
price: Number(it.prod_price) || 0,
|
||||||
|
quantity: Number(it.qty_ordered) || 0,
|
||||||
|
imageUrl: imageUrl(it.prod_pid, iid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders.map((o) => ({
|
||||||
|
orderId: Number(o.order_id),
|
||||||
|
datePlaced: o.date_placed,
|
||||||
|
total: Number(o.summary_total) || 0,
|
||||||
|
status: Number(o.order_status),
|
||||||
|
statusLabel: statusLabel(Number(o.order_status)),
|
||||||
|
statusShort: statusShort(Number(o.order_status)),
|
||||||
|
trackingNumber: o.ship_method_tracking || '',
|
||||||
|
trackingUrl: trackingLink(o.ship_method_type, o.ship_method_tracking),
|
||||||
|
items: itemsByOrder.get(Number(o.order_id)) || [],
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, orders: data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('customers/:cid/orders failed:', err);
|
||||||
|
res.status(500).json({ success: false, error: 'query_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
const { getDbConnection } = require('../db/connection');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Bucket boundaries by summary_subtotal (post-item-sale, pre-order-promo).
|
||||||
|
// The final entry is open-ended: all orders >= the last bound land there.
|
||||||
|
const RANGE_BOUNDS = [
|
||||||
|
10, 20, 30, 40, 50, 60, 70, 80, 90,
|
||||||
|
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
|
||||||
|
300, 400, 500, 1000, 1500
|
||||||
|
];
|
||||||
|
|
||||||
|
const FINAL_BUCKET_KEY = '99999';
|
||||||
|
|
||||||
|
function buildRangeDefinitions() {
|
||||||
|
const ranges = [];
|
||||||
|
let previous = 0;
|
||||||
|
for (const bound of RANGE_BOUNDS) {
|
||||||
|
const key = bound.toString().padStart(5, '0');
|
||||||
|
ranges.push({
|
||||||
|
min: previous,
|
||||||
|
max: bound,
|
||||||
|
label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
previous = bound;
|
||||||
|
}
|
||||||
|
const lastBound = RANGE_BOUNDS[RANGE_BOUNDS.length - 1];
|
||||||
|
ranges.push({
|
||||||
|
min: lastBound,
|
||||||
|
max: null,
|
||||||
|
label: `$${lastBound.toLocaleString()}+`,
|
||||||
|
key: FINAL_BUCKET_KEY,
|
||||||
|
});
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_DEFINITIONS = buildRangeDefinitions();
|
||||||
|
|
||||||
|
function bucketKeyFor(subtotal) {
|
||||||
|
for (const range of RANGE_DEFINITIONS) {
|
||||||
|
if (range.max == null) return range.key;
|
||||||
|
if (subtotal <= range.max) return range.key;
|
||||||
|
}
|
||||||
|
return FINAL_BUCKET_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
merchantFeePercent: 2.9,
|
||||||
|
fixedCostPerOrder: 1.25,
|
||||||
|
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDate(value, fallback) {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = DateTime.fromISO(value);
|
||||||
|
if (!parsed.isValid) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForSql(dt) {
|
||||||
|
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/promos', async (req, res) => {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
const { connection: conn, release } = await getDbConnection();
|
||||||
|
connection = conn;
|
||||||
|
const releaseConnection = release;
|
||||||
|
|
||||||
|
const { startDate, endDate } = req.query || {};
|
||||||
|
const now = DateTime.now().endOf('day');
|
||||||
|
const defaultStart = now.minus({ years: 3 }).startOf('day');
|
||||||
|
|
||||||
|
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
|
||||||
|
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
|
||||||
|
|
||||||
|
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
|
||||||
|
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
|
||||||
|
|
||||||
|
const rangeStartSql = formatDateForSql(rangeStart);
|
||||||
|
const rangeEndSql = formatDateForSql(rangeEnd);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
p.promo_id AS id,
|
||||||
|
p.promo_code AS code,
|
||||||
|
p.promo_description_online AS description_online,
|
||||||
|
p.promo_description_private AS description_private,
|
||||||
|
p.date_start,
|
||||||
|
p.date_end,
|
||||||
|
COALESCE(u.usage_count, 0) AS usage_count
|
||||||
|
FROM promos p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
discount_code,
|
||||||
|
COUNT(DISTINCT order_id) AS usage_count
|
||||||
|
FROM order_discounts
|
||||||
|
WHERE discount_type = 10 AND discount_active = 1
|
||||||
|
GROUP BY discount_code
|
||||||
|
) u ON u.discount_code = p.promo_id
|
||||||
|
WHERE p.date_start IS NOT NULL
|
||||||
|
AND p.date_end IS NOT NULL
|
||||||
|
AND NOT (p.date_end < ? OR p.date_start > ?)
|
||||||
|
AND p.store = 1
|
||||||
|
AND p.date_start >= '2010-01-01'
|
||||||
|
ORDER BY p.promo_id DESC
|
||||||
|
LIMIT 200
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]);
|
||||||
|
releaseConnection();
|
||||||
|
|
||||||
|
const promos = rows.map(row => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
code: row.code,
|
||||||
|
description: row.description_online || row.description_private || '',
|
||||||
|
privateDescription: row.description_private || '',
|
||||||
|
promo_description_online: row.description_online || '',
|
||||||
|
promo_description_private: row.description_private || '',
|
||||||
|
dateStart: row.date_start,
|
||||||
|
dateEnd: row.date_end,
|
||||||
|
usageCount: Number(row.usage_count || 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ promos });
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
connection.destroy();
|
||||||
|
} catch (destroyError) {
|
||||||
|
console.error('Failed to destroy connection after error:', destroyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error fetching promos:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch promos' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function emptyBucketAccumulator(range) {
|
||||||
|
return {
|
||||||
|
key: range.key,
|
||||||
|
label: range.label,
|
||||||
|
min: range.min,
|
||||||
|
max: range.max,
|
||||||
|
orderCount: 0,
|
||||||
|
sumOrderValue: 0,
|
||||||
|
sumProductDiscountAmount: 0,
|
||||||
|
sumPromoProductDiscount: 0,
|
||||||
|
sumCustomerItemCost: 0,
|
||||||
|
sumShippingChargeBase: 0,
|
||||||
|
sumShippingAfterAuto: 0,
|
||||||
|
sumShipPromoDiscount: 0,
|
||||||
|
sumShippingSurcharge: 0,
|
||||||
|
sumOrderSurcharge: 0,
|
||||||
|
sumCustomerShipCost: 0,
|
||||||
|
sumActualShippingCost: 0,
|
||||||
|
sumTotalRevenue: 0,
|
||||||
|
sumProductCogs: 0,
|
||||||
|
sumMerchantFees: 0,
|
||||||
|
sumPointsCost: 0,
|
||||||
|
sumFixedCosts: 0,
|
||||||
|
sumTotalCosts: 0,
|
||||||
|
sumProfit: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateOrder(order, config, derived) {
|
||||||
|
const orderValue = Number(order.summary_subtotal) || 0;
|
||||||
|
const retail = Number(order.summary_subtotal_retail) || orderValue;
|
||||||
|
const productDiscountAmount = Number(order.summary_discount_subtotal) || 0;
|
||||||
|
const pointsRedeemedDollars = Number(order.points_redeemed) || 0;
|
||||||
|
// summary_discount_subtotal is a kitchen-sink rollup that includes points
|
||||||
|
// redemptions (type 20). pointsCost already accrues for points awarded, so
|
||||||
|
// the points portion of historical discount must be excluded here to avoid
|
||||||
|
// double-counting it on orders that redeemed points.
|
||||||
|
const historicalProductDiscountExPoints = Math.max(0, productDiscountAmount - pointsRedeemedDollars);
|
||||||
|
const shippingChargeBase =
|
||||||
|
(Number(order.summary_shipping) || 0) + (Number(order.summary_shipping_rush) || 0);
|
||||||
|
const actualShippingCost = Number(order.ship_method_cost) || 0;
|
||||||
|
const cogs = Number(order.total_cogs) || 0;
|
||||||
|
|
||||||
|
let promoProductDiscount = 0;
|
||||||
|
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = orderValue * (config.productPromo.value / 100);
|
||||||
|
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
const targetRate = config.productPromo.value / 100;
|
||||||
|
const targetCustomerPrice = retail * (1 - targetRate);
|
||||||
|
promoProductDiscount = Math.max(0, orderValue - targetCustomerPrice);
|
||||||
|
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = config.productPromo.value;
|
||||||
|
} else if (config.productPromo.type === 'none' && config.applyHistoricalProductPromo) {
|
||||||
|
promoProductDiscount = historicalProductDiscountExPoints;
|
||||||
|
}
|
||||||
|
promoProductDiscount = Math.max(0, Math.min(promoProductDiscount, orderValue));
|
||||||
|
|
||||||
|
let shippingAfterAuto = shippingChargeBase;
|
||||||
|
for (const tier of config.shippingTiers) {
|
||||||
|
if (orderValue >= tier.threshold) {
|
||||||
|
if (tier.mode === 'percentage') {
|
||||||
|
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
|
||||||
|
} else if (tier.mode === 'flat') {
|
||||||
|
shippingAfterAuto = tier.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipPromoDiscount = 0;
|
||||||
|
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
|
||||||
|
if (config.shippingPromo.type === 'percentage') {
|
||||||
|
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
|
||||||
|
} else if (config.shippingPromo.type === 'fixed') {
|
||||||
|
shipPromoDiscount = config.shippingPromo.value;
|
||||||
|
}
|
||||||
|
if (config.shippingPromo.maxDiscount > 0) {
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
|
||||||
|
}
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shippingSurcharge = 0;
|
||||||
|
let orderSurcharge = 0;
|
||||||
|
for (const surcharge of config.surcharges) {
|
||||||
|
const meetsMin = orderValue >= surcharge.threshold;
|
||||||
|
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
|
||||||
|
if (meetsMin && meetsMax) {
|
||||||
|
if (surcharge.target === 'shipping') shippingSurcharge += surcharge.amount;
|
||||||
|
else if (surcharge.target === 'order') orderSurcharge += surcharge.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
|
||||||
|
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
|
||||||
|
const totalRevenue = customerItemCost + customerShipCost;
|
||||||
|
|
||||||
|
const productCogs = config.cogsCalculationMode === 'average'
|
||||||
|
? orderValue * derived.overallCogsPercentage
|
||||||
|
: cogs;
|
||||||
|
|
||||||
|
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
||||||
|
const pointsCost = orderValue * derived.pointsPerDollar * derived.redemptionRate * derived.pointDollarValue;
|
||||||
|
const fixedCosts = config.fixedCostPerOrder;
|
||||||
|
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
|
||||||
|
const profit = totalRevenue - totalCosts;
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderValue,
|
||||||
|
productDiscountAmount,
|
||||||
|
promoProductDiscount,
|
||||||
|
customerItemCost,
|
||||||
|
shippingChargeBase,
|
||||||
|
shippingAfterAuto,
|
||||||
|
shipPromoDiscount,
|
||||||
|
shippingSurcharge,
|
||||||
|
orderSurcharge,
|
||||||
|
customerShipCost,
|
||||||
|
actualShippingCost,
|
||||||
|
totalRevenue,
|
||||||
|
productCogs,
|
||||||
|
merchantFees,
|
||||||
|
pointsCost,
|
||||||
|
fixedCosts,
|
||||||
|
totalCosts,
|
||||||
|
profit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulate(bucket, sim) {
|
||||||
|
bucket.orderCount += 1;
|
||||||
|
bucket.sumOrderValue += sim.orderValue;
|
||||||
|
bucket.sumProductDiscountAmount += sim.productDiscountAmount;
|
||||||
|
bucket.sumPromoProductDiscount += sim.promoProductDiscount;
|
||||||
|
bucket.sumCustomerItemCost += sim.customerItemCost;
|
||||||
|
bucket.sumShippingChargeBase += sim.shippingChargeBase;
|
||||||
|
bucket.sumShippingAfterAuto += sim.shippingAfterAuto;
|
||||||
|
bucket.sumShipPromoDiscount += sim.shipPromoDiscount;
|
||||||
|
bucket.sumShippingSurcharge += sim.shippingSurcharge;
|
||||||
|
bucket.sumOrderSurcharge += sim.orderSurcharge;
|
||||||
|
bucket.sumCustomerShipCost += sim.customerShipCost;
|
||||||
|
bucket.sumActualShippingCost += sim.actualShippingCost;
|
||||||
|
bucket.sumTotalRevenue += sim.totalRevenue;
|
||||||
|
bucket.sumProductCogs += sim.productCogs;
|
||||||
|
bucket.sumMerchantFees += sim.merchantFees;
|
||||||
|
bucket.sumPointsCost += sim.pointsCost;
|
||||||
|
bucket.sumFixedCosts += sim.fixedCosts;
|
||||||
|
bucket.sumTotalCosts += sim.totalCosts;
|
||||||
|
bucket.sumProfit += sim.profit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeBucket(b, totalOrders) {
|
||||||
|
const n = b.orderCount;
|
||||||
|
const avg = (sum) => (n > 0 ? sum / n : 0);
|
||||||
|
return {
|
||||||
|
key: b.key,
|
||||||
|
label: b.label,
|
||||||
|
min: b.min,
|
||||||
|
max: b.max,
|
||||||
|
orderCount: n,
|
||||||
|
weight: totalOrders > 0 ? n / totalOrders : 0,
|
||||||
|
orderValue: avg(b.sumOrderValue),
|
||||||
|
productDiscountAmount: avg(b.sumProductDiscountAmount),
|
||||||
|
promoProductDiscount: avg(b.sumPromoProductDiscount),
|
||||||
|
customerItemCost: avg(b.sumCustomerItemCost),
|
||||||
|
shippingChargeBase: avg(b.sumShippingChargeBase),
|
||||||
|
shippingAfterAuto: avg(b.sumShippingAfterAuto),
|
||||||
|
shipPromoDiscount: avg(b.sumShipPromoDiscount),
|
||||||
|
shippingSurcharge: avg(b.sumShippingSurcharge),
|
||||||
|
orderSurcharge: avg(b.sumOrderSurcharge),
|
||||||
|
customerShipCost: avg(b.sumCustomerShipCost),
|
||||||
|
actualShippingCost: avg(b.sumActualShippingCost),
|
||||||
|
totalRevenue: avg(b.sumTotalRevenue),
|
||||||
|
productCogs: avg(b.sumProductCogs),
|
||||||
|
merchantFees: avg(b.sumMerchantFees),
|
||||||
|
pointsCost: avg(b.sumPointsCost),
|
||||||
|
fixedCosts: avg(b.sumFixedCosts),
|
||||||
|
totalCosts: avg(b.sumTotalCosts),
|
||||||
|
profit: avg(b.sumProfit),
|
||||||
|
profitPercent: b.sumTotalRevenue > 0 ? b.sumProfit / b.sumTotalRevenue : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/simulate', async (req, res) => {
|
||||||
|
const {
|
||||||
|
dateRange = {},
|
||||||
|
filters = {},
|
||||||
|
productPromo = {},
|
||||||
|
shippingPromo = {},
|
||||||
|
shippingTiers = [],
|
||||||
|
surcharges = [],
|
||||||
|
merchantFeePercent,
|
||||||
|
fixedCostPerOrder,
|
||||||
|
cogsCalculationMode = 'actual',
|
||||||
|
applyHistoricalProductPromo = false,
|
||||||
|
pointsConfig = {}
|
||||||
|
} = req.body || {};
|
||||||
|
|
||||||
|
const endDefault = DateTime.now();
|
||||||
|
const startDefault = endDefault.minus({ months: 6 });
|
||||||
|
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
|
||||||
|
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
||||||
|
|
||||||
|
const shipCountry = filters.shipCountry || 'US';
|
||||||
|
const promoIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
|
||||||
|
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
|
||||||
|
]
|
||||||
|
.map((value) => {
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
if (typeof value === 'number') return String(value);
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
|
||||||
|
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
|
||||||
|
cogsCalculationMode,
|
||||||
|
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
|
||||||
|
productPromo: {
|
||||||
|
type: productPromo.type || 'none',
|
||||||
|
value: Number(productPromo.value || 0),
|
||||||
|
minSubtotal: Number(productPromo.minSubtotal || 0)
|
||||||
|
},
|
||||||
|
shippingPromo: {
|
||||||
|
type: shippingPromo.type || 'none',
|
||||||
|
value: Number(shippingPromo.value || 0),
|
||||||
|
minSubtotal: Number(shippingPromo.minSubtotal || 0),
|
||||||
|
maxDiscount: Number(shippingPromo.maxDiscount || 0)
|
||||||
|
},
|
||||||
|
shippingTiers: Array.isArray(shippingTiers)
|
||||||
|
? shippingTiers
|
||||||
|
.map(tier => ({
|
||||||
|
threshold: Number(tier.threshold || 0),
|
||||||
|
mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage',
|
||||||
|
value: Number(tier.value || 0)
|
||||||
|
}))
|
||||||
|
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
|
||||||
|
.sort((a, b) => a.threshold - b.threshold)
|
||||||
|
: [],
|
||||||
|
surcharges: Array.isArray(surcharges)
|
||||||
|
? surcharges
|
||||||
|
.map(s => ({
|
||||||
|
threshold: Number(s.threshold || 0),
|
||||||
|
maxThreshold: typeof s.maxThreshold === 'number' && s.maxThreshold > 0 ? s.maxThreshold : null,
|
||||||
|
target: s.target === 'shipping' || s.target === 'order' ? s.target : 'shipping',
|
||||||
|
amount: Number(s.amount || 0)
|
||||||
|
}))
|
||||||
|
.filter(s => s.threshold >= 0 && s.amount >= 0)
|
||||||
|
.sort((a, b) => a.threshold - b.threshold)
|
||||||
|
: [],
|
||||||
|
points: {
|
||||||
|
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
|
||||||
|
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null,
|
||||||
|
pointDollarValue: typeof pointsConfig.pointDollarValue === 'number'
|
||||||
|
? pointsConfig.pointDollarValue
|
||||||
|
: DEFAULT_POINT_DOLLAR_VALUE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
let release;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbConn = await getDbConnection();
|
||||||
|
connection = dbConn.connection;
|
||||||
|
release = dbConn.release;
|
||||||
|
|
||||||
|
const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
|
||||||
|
let promoExistsClause = '';
|
||||||
|
if (promoIds.length > 0) {
|
||||||
|
const placeholders = promoIds.map(() => '?').join(',');
|
||||||
|
promoExistsClause = `
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM order_discounts od
|
||||||
|
WHERE od.order_id = o.order_id
|
||||||
|
AND od.discount_active = 1
|
||||||
|
AND od.discount_type = 10
|
||||||
|
AND od.discount_code IN (${placeholders})
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
params.push(...promoIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordersQuery = `
|
||||||
|
SELECT
|
||||||
|
o.order_id,
|
||||||
|
o.summary_subtotal,
|
||||||
|
COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
|
||||||
|
COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
|
||||||
|
COALESCE(o.summary_shipping, 0) AS summary_shipping,
|
||||||
|
COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
|
||||||
|
COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
|
||||||
|
COALESCE(o.summary_points, 0) AS summary_points,
|
||||||
|
COALESCE(c.total_cogs, 0) AS total_cogs,
|
||||||
|
COALESCE(p.points_redeemed, 0) AS points_redeemed
|
||||||
|
FROM _order o
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(cogs_amount) AS total_cogs
|
||||||
|
FROM report_sales_data
|
||||||
|
WHERE action IN (1,2,3)
|
||||||
|
GROUP BY order_id
|
||||||
|
) c ON c.order_id = o.order_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
|
||||||
|
FROM order_discounts
|
||||||
|
WHERE discount_type = 20 AND discount_active = 1
|
||||||
|
GROUP BY order_id
|
||||||
|
) p ON p.order_id = o.order_id
|
||||||
|
WHERE o.summary_total > 0
|
||||||
|
AND o.order_status >= 20
|
||||||
|
AND o.ship_method_selected <> 'holdit'
|
||||||
|
AND o.ship_country = ?
|
||||||
|
AND o.date_placed BETWEEN ? AND ?
|
||||||
|
${promoExistsClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [orders] = await connection.execute(ordersQuery, params);
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
release();
|
||||||
|
release = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSubtotal = 0;
|
||||||
|
let totalProductDiscount = 0;
|
||||||
|
let totalCogs = 0;
|
||||||
|
let totalPointsAwarded = 0;
|
||||||
|
let totalPointsRedeemedDollars = 0;
|
||||||
|
for (const o of orders) {
|
||||||
|
totalSubtotal += Number(o.summary_subtotal) || 0;
|
||||||
|
totalProductDiscount += Number(o.summary_discount_subtotal) || 0;
|
||||||
|
totalCogs += Number(o.total_cogs) || 0;
|
||||||
|
totalPointsAwarded += Number(o.summary_points) || 0;
|
||||||
|
totalPointsRedeemedDollars += Number(o.points_redeemed) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productDiscountRate = totalSubtotal > 0 ? totalProductDiscount / totalSubtotal : 0;
|
||||||
|
const overallCogsPercentage = totalSubtotal > 0 ? totalCogs / totalSubtotal : 0;
|
||||||
|
const pointsPerDollar = config.points.pointsPerDollar != null
|
||||||
|
? config.points.pointsPerDollar
|
||||||
|
: (totalSubtotal > 0 ? totalPointsAwarded / totalSubtotal : 0);
|
||||||
|
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
||||||
|
let redemptionRate;
|
||||||
|
if (config.points.redemptionRate != null) {
|
||||||
|
redemptionRate = config.points.redemptionRate;
|
||||||
|
} else if (totalPointsAwarded > 0 && pointDollarValue > 0) {
|
||||||
|
const totalRedeemedPoints = totalPointsRedeemedDollars / pointDollarValue;
|
||||||
|
redemptionRate = Math.min(1, totalRedeemedPoints / totalPointsAwarded);
|
||||||
|
} else {
|
||||||
|
redemptionRate = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const derived = {
|
||||||
|
overallCogsPercentage,
|
||||||
|
pointsPerDollar,
|
||||||
|
redemptionRate,
|
||||||
|
pointDollarValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const range of RANGE_DEFINITIONS) {
|
||||||
|
buckets.set(range.key, emptyBucketAccumulator(range));
|
||||||
|
}
|
||||||
|
|
||||||
|
let grandTotalProfit = 0;
|
||||||
|
let grandTotalRevenue = 0;
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const sim = simulateOrder(order, config, derived);
|
||||||
|
const bucketKey = bucketKeyFor(sim.orderValue);
|
||||||
|
const bucket = buckets.get(bucketKey);
|
||||||
|
accumulate(bucket, sim);
|
||||||
|
grandTotalProfit += sim.profit;
|
||||||
|
grandTotalRevenue += sim.totalRevenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOrders = orders.length;
|
||||||
|
const bucketResults = RANGE_DEFINITIONS.map((range) =>
|
||||||
|
finalizeBucket(buckets.get(range.key), totalOrders)
|
||||||
|
);
|
||||||
|
|
||||||
|
const weightedProfitAmount = totalOrders > 0 ? grandTotalProfit / totalOrders : 0;
|
||||||
|
const weightedProfitPercent = grandTotalRevenue > 0 ? grandTotalProfit / grandTotalRevenue : 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dateRange: {
|
||||||
|
start: startDt.toISO(),
|
||||||
|
end: endDt.toISO()
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
orders: totalOrders,
|
||||||
|
subtotal: totalSubtotal,
|
||||||
|
productDiscountRate,
|
||||||
|
pointsPerDollar,
|
||||||
|
redemptionRate,
|
||||||
|
pointDollarValue,
|
||||||
|
weightedProfitAmount,
|
||||||
|
weightedProfitPercent,
|
||||||
|
overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined
|
||||||
|
},
|
||||||
|
buckets: bucketResults
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
release();
|
||||||
|
} catch (releaseError) {
|
||||||
|
console.error('Failed to release connection after error:', releaseError);
|
||||||
|
}
|
||||||
|
} else if (connection) {
|
||||||
|
try {
|
||||||
|
connection.destroy();
|
||||||
|
} catch (destroyError) {
|
||||||
|
console.error('Failed to destroy connection after error:', destroyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error running discount simulation:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run discount simulation' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||||
|
const {
|
||||||
|
getTimeRangeConditions,
|
||||||
|
_internal: timeHelpers
|
||||||
|
} = require('../utils/timeUtils');
|
||||||
|
|
||||||
|
const TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
// Punch types from the database
|
||||||
|
const PUNCH_TYPES = {
|
||||||
|
OUT: 0,
|
||||||
|
IN: 1,
|
||||||
|
BREAK_START: 2,
|
||||||
|
BREAK_END: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Standard hours for FTE calculation (40 hours per week)
|
||||||
|
const STANDARD_WEEKLY_HOURS = 40;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate working hours from timeclock entries
|
||||||
|
* Groups punches by employee and date, pairs in/out punches
|
||||||
|
* Returns both total hours (with breaks, for FTE) and productive hours (without breaks, for productivity)
|
||||||
|
*/
|
||||||
|
function calculateHoursFromPunches(punches) {
|
||||||
|
// Group by employee
|
||||||
|
const byEmployee = new Map();
|
||||||
|
|
||||||
|
punches.forEach(punch => {
|
||||||
|
if (!byEmployee.has(punch.EmployeeID)) {
|
||||||
|
byEmployee.set(punch.EmployeeID, []);
|
||||||
|
}
|
||||||
|
byEmployee.get(punch.EmployeeID).push(punch);
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeeHours = [];
|
||||||
|
let totalHours = 0;
|
||||||
|
let totalBreakHours = 0;
|
||||||
|
|
||||||
|
byEmployee.forEach((employeePunches, employeeId) => {
|
||||||
|
// Sort by timestamp
|
||||||
|
employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
|
||||||
|
|
||||||
|
let hours = 0;
|
||||||
|
let breakHours = 0;
|
||||||
|
let currentIn = null;
|
||||||
|
let breakStart = null;
|
||||||
|
|
||||||
|
employeePunches.forEach(punch => {
|
||||||
|
const punchTime = new Date(punch.TimeStamp);
|
||||||
|
|
||||||
|
switch (punch.PunchType) {
|
||||||
|
case PUNCH_TYPES.IN:
|
||||||
|
currentIn = punchTime;
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.OUT:
|
||||||
|
if (currentIn) {
|
||||||
|
hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours
|
||||||
|
currentIn = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.BREAK_START:
|
||||||
|
breakStart = punchTime;
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.BREAK_END:
|
||||||
|
if (breakStart) {
|
||||||
|
breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
|
||||||
|
breakStart = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalHours += hours;
|
||||||
|
totalBreakHours += breakHours;
|
||||||
|
|
||||||
|
employeeHours.push({
|
||||||
|
employeeId,
|
||||||
|
hours,
|
||||||
|
breakHours,
|
||||||
|
productiveHours: hours - breakHours,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeHours,
|
||||||
|
totalHours,
|
||||||
|
totalBreakHours,
|
||||||
|
totalProductiveHours: totalHours - totalBreakHours
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate FTE (Full Time Equivalents) for a period
|
||||||
|
* @param {number} totalHours - Total hours worked
|
||||||
|
* @param {Date} startDate - Period start
|
||||||
|
* @param {Date} endDate - Period end
|
||||||
|
*/
|
||||||
|
function calculateFTE(totalHours, startDate, endDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const days = Math.max(1, (end - start) / (1000 * 60 * 60 * 24));
|
||||||
|
const weeks = days / 7;
|
||||||
|
const expectedHours = weeks * STANDARD_WEEKLY_HOURS;
|
||||||
|
|
||||||
|
return expectedHours > 0 ? totalHours / expectedHours : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main employee metrics endpoint
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[EMPLOYEE-METRICS] Starting request for timeRange: ${req.query.timeRange}`);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mainOperation = async () => {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
console.log(`[EMPLOYEE-METRICS] Getting DB connection...`);
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
console.log(`[EMPLOYEE-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
|
// Adapt where clause for timeclock table (uses TimeStamp instead of date_placed)
|
||||||
|
const timeclockWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
||||||
|
|
||||||
|
// Query for timeclock data with employee names
|
||||||
|
const timeclockQuery = `
|
||||||
|
SELECT
|
||||||
|
tc.EmployeeID,
|
||||||
|
tc.TimeStamp,
|
||||||
|
tc.PunchType,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname
|
||||||
|
FROM timeclock tc
|
||||||
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
||||||
|
WHERE ${timeclockWhere}
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
ORDER BY tc.EmployeeID, tc.TimeStamp
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [timeclockRows] = await connection.execute(timeclockQuery, params);
|
||||||
|
|
||||||
|
// Calculate hours (includes both total hours for FTE and productive hours for productivity)
|
||||||
|
const { employeeHours, totalHours, totalBreakHours, totalProductiveHours } = calculateHoursFromPunches(timeclockRows);
|
||||||
|
|
||||||
|
// Get employee names for the results
|
||||||
|
const employeeNames = new Map();
|
||||||
|
timeclockRows.forEach(row => {
|
||||||
|
if (!employeeNames.has(row.EmployeeID)) {
|
||||||
|
employeeNames.set(row.EmployeeID, {
|
||||||
|
firstname: row.firstname || '',
|
||||||
|
lastname: row.lastname || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich employee hours with names
|
||||||
|
const enrichedEmployeeHours = employeeHours.map(eh => ({
|
||||||
|
...eh,
|
||||||
|
name: employeeNames.has(eh.employeeId)
|
||||||
|
? `${employeeNames.get(eh.employeeId).firstname} ${employeeNames.get(eh.employeeId).lastname}`.trim()
|
||||||
|
: `Employee ${eh.employeeId}`,
|
||||||
|
})).sort((a, b) => b.hours - a.hours);
|
||||||
|
|
||||||
|
// Query for picking tickets - using subquery to avoid duplication from bucket join
|
||||||
|
// Ship-together orders: only count main orders (is_sub = 0 or NULL), not sub-orders
|
||||||
|
const pickingWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
|
||||||
|
// First get picking ticket stats without the bucket join (to avoid duplication)
|
||||||
|
const pickingStatsQuery = `
|
||||||
|
SELECT
|
||||||
|
pt.createdby as employeeId,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname,
|
||||||
|
COUNT(DISTINCT pt.pickingid) as ticketCount,
|
||||||
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
||||||
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds,
|
||||||
|
AVG(NULLIF(pt.picking_speed, 0)) as avgPickingSpeed
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN employees e ON pt.createdby = e.employeeid
|
||||||
|
WHERE ${pickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY pt.createdby, e.firstname, e.lastname
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Separate query for order counts (needs bucket join for ship-together handling)
|
||||||
|
const orderCountQuery = `
|
||||||
|
SELECT
|
||||||
|
pt.createdby as employeeId,
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${pickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY pt.createdby
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [[pickingStatsRows], [orderCountRows]] = await Promise.all([
|
||||||
|
connection.execute(pickingStatsQuery, params),
|
||||||
|
connection.execute(orderCountQuery, params)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge the results
|
||||||
|
const orderCountMap = new Map();
|
||||||
|
orderCountRows.forEach(row => {
|
||||||
|
orderCountMap.set(row.employeeId, parseInt(row.ordersPicked || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate picking totals
|
||||||
|
let totalOrdersPicked = 0;
|
||||||
|
let totalPiecesPicked = 0;
|
||||||
|
let totalTickets = 0;
|
||||||
|
let totalPickingTimeSeconds = 0;
|
||||||
|
let pickingSpeedSum = 0;
|
||||||
|
let pickingSpeedCount = 0;
|
||||||
|
|
||||||
|
const pickingByEmployee = pickingStatsRows.map(row => {
|
||||||
|
const ordersPicked = orderCountMap.get(row.employeeId) || 0;
|
||||||
|
totalOrdersPicked += ordersPicked;
|
||||||
|
totalPiecesPicked += parseInt(row.piecesPicked || 0);
|
||||||
|
totalTickets += parseInt(row.ticketCount || 0);
|
||||||
|
totalPickingTimeSeconds += parseInt(row.pickingTimeSeconds || 0);
|
||||||
|
if (row.avgPickingSpeed && row.avgPickingSpeed > 0) {
|
||||||
|
pickingSpeedSum += parseFloat(row.avgPickingSpeed);
|
||||||
|
pickingSpeedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const empPickingHours = parseInt(row.pickingTimeSeconds || 0) / 3600;
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeId}`,
|
||||||
|
ticketCount: parseInt(row.ticketCount || 0),
|
||||||
|
ordersPicked,
|
||||||
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
||||||
|
pickingHours: empPickingHours,
|
||||||
|
avgPickingSpeed: row.avgPickingSpeed ? parseFloat(row.avgPickingSpeed) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPickingHours = totalPickingTimeSeconds / 3600;
|
||||||
|
const avgPickingSpeed = pickingSpeedCount > 0 ? pickingSpeedSum / pickingSpeedCount : 0;
|
||||||
|
|
||||||
|
// Query for shipped orders - totals
|
||||||
|
// Ship-together orders: only count main orders (order_type != 8 for sub-orders, or use parent tracking)
|
||||||
|
const shippingWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||||
|
|
||||||
|
const shippingQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
WHERE ${shippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [shippingRows] = await connection.execute(shippingQuery, params);
|
||||||
|
const shipping = shippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
||||||
|
|
||||||
|
// Query for shipped orders by employee
|
||||||
|
const shippingByEmployeeQuery = `
|
||||||
|
SELECT
|
||||||
|
e.employeeid,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname,
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
JOIN employees e ON o.stats_cid_shipped = e.cid
|
||||||
|
WHERE ${shippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
GROUP BY e.employeeid, e.firstname, e.lastname
|
||||||
|
ORDER BY ordersShipped DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [shippingByEmployeeRows] = await connection.execute(shippingByEmployeeQuery, params);
|
||||||
|
const shippingByEmployee = shippingByEmployeeRows.map(row => ({
|
||||||
|
employeeId: row.employeeid,
|
||||||
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeid}`,
|
||||||
|
ordersShipped: parseInt(row.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(row.piecesShipped || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate period dates for FTE calculation
|
||||||
|
let periodStart, periodEnd;
|
||||||
|
if (dateRange?.start) {
|
||||||
|
periodStart = new Date(dateRange.start);
|
||||||
|
} else if (params[0]) {
|
||||||
|
periodStart = new Date(params[0]);
|
||||||
|
} else {
|
||||||
|
periodStart = new Date();
|
||||||
|
periodStart.setDate(periodStart.getDate() - 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange?.end) {
|
||||||
|
periodEnd = new Date(dateRange.end);
|
||||||
|
} else if (params[1]) {
|
||||||
|
periodEnd = new Date(params[1]);
|
||||||
|
} else {
|
||||||
|
periodEnd = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fte = calculateFTE(totalHours, periodStart, periodEnd);
|
||||||
|
const activeEmployees = enrichedEmployeeHours.filter(e => e.hours > 0).length;
|
||||||
|
|
||||||
|
// Calculate weeks in period for weekly averages
|
||||||
|
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
|
||||||
|
const weeksInPeriod = periodDays / 7;
|
||||||
|
|
||||||
|
// Get daily trend data for hours
|
||||||
|
// Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues
|
||||||
|
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
||||||
|
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
||||||
|
const trendQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||||
|
tc.EmployeeID,
|
||||||
|
tc.TimeStamp,
|
||||||
|
tc.PunchType
|
||||||
|
FROM timeclock tc
|
||||||
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
||||||
|
WHERE ${trendWhere}
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
ORDER BY date, tc.EmployeeID, tc.TimeStamp
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [trendRows] = await connection.execute(trendQuery, params);
|
||||||
|
|
||||||
|
// Get daily picking data for trend
|
||||||
|
// Ship-together orders: only count main orders (is_sub = 0 or NULL)
|
||||||
|
// Use DATE_FORMAT for consistent date string format
|
||||||
|
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
const pickingTrendQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
|
||||||
|
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${pickingTrendWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [pickingTrendRows] = await connection.execute(pickingTrendQuery, params);
|
||||||
|
|
||||||
|
// Create a map of picking data by date
|
||||||
|
const pickingByDate = new Map();
|
||||||
|
pickingTrendRows.forEach(row => {
|
||||||
|
// Date is already a string in YYYY-MM-DD format from DATE_FORMAT
|
||||||
|
const date = String(row.date);
|
||||||
|
pickingByDate.set(date, {
|
||||||
|
ordersPicked: parseInt(row.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group timeclock by date for trend
|
||||||
|
const byDate = new Map();
|
||||||
|
trendRows.forEach(row => {
|
||||||
|
// Date is already a string in YYYY-MM-DD format from DATE_FORMAT
|
||||||
|
const date = String(row.date);
|
||||||
|
if (!byDate.has(date)) {
|
||||||
|
byDate.set(date, []);
|
||||||
|
}
|
||||||
|
byDate.get(date).push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate all dates in the period range for complete trend data
|
||||||
|
const allDatesInRange = [];
|
||||||
|
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
|
||||||
|
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
|
||||||
|
|
||||||
|
let currentDt = startDt;
|
||||||
|
while (currentDt <= endDt) {
|
||||||
|
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
|
||||||
|
currentDt = currentDt.plus({ days: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trend data for all dates in range, filling zeros for missing days
|
||||||
|
const trend = allDatesInRange.map(date => {
|
||||||
|
const punches = byDate.get(date) || [];
|
||||||
|
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
|
||||||
|
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
|
||||||
|
|
||||||
|
// Parse date string in Eastern timezone to get proper ISO timestamp
|
||||||
|
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timestamp: dateDt.toISO(),
|
||||||
|
hours: dayHours,
|
||||||
|
activeEmployees: dayEmployeeHours.filter(e => e.hours > 0).length,
|
||||||
|
ordersPicked: picking.ordersPicked,
|
||||||
|
piecesPicked: picking.piecesPicked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get previous period data for comparison
|
||||||
|
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
|
||||||
|
let comparison = null;
|
||||||
|
let previousTotals = null;
|
||||||
|
|
||||||
|
if (previousRange) {
|
||||||
|
const prevTimeclockWhere = previousRange.whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
||||||
|
|
||||||
|
const [prevTimeclockRows] = await connection.execute(
|
||||||
|
`SELECT tc.EmployeeID, tc.TimeStamp, tc.PunchType
|
||||||
|
FROM timeclock tc
|
||||||
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
||||||
|
WHERE ${prevTimeclockWhere}
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
ORDER BY tc.EmployeeID, tc.TimeStamp`,
|
||||||
|
previousRange.params
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
totalHours: prevTotalHours,
|
||||||
|
totalProductiveHours: prevProductiveHours,
|
||||||
|
employeeHours: prevEmployeeHours
|
||||||
|
} = calculateHoursFromPunches(prevTimeclockRows);
|
||||||
|
const prevActiveEmployees = prevEmployeeHours.filter(e => e.hours > 0).length;
|
||||||
|
|
||||||
|
// Previous picking data (ship-together fix applied)
|
||||||
|
// Use separate queries to avoid duplication from bucket join
|
||||||
|
const prevPickingWhere = previousRange.whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
|
||||||
|
const [[prevPickingStatsRows], [prevOrderCountRows]] = await Promise.all([
|
||||||
|
connection.execute(
|
||||||
|
`SELECT
|
||||||
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
||||||
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds
|
||||||
|
FROM picking_ticket pt
|
||||||
|
WHERE ${prevPickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL`,
|
||||||
|
previousRange.params
|
||||||
|
),
|
||||||
|
connection.execute(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${prevPickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL`,
|
||||||
|
previousRange.params
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prevPickingStats = prevPickingStatsRows[0] || { piecesPicked: 0, pickingTimeSeconds: 0 };
|
||||||
|
const prevOrderCount = prevOrderCountRows[0] || { ordersPicked: 0 };
|
||||||
|
const prevPicking = {
|
||||||
|
ordersPicked: parseInt(prevOrderCount.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(prevPickingStats.piecesPicked || 0),
|
||||||
|
pickingTimeSeconds: parseInt(prevPickingStats.pickingTimeSeconds || 0)
|
||||||
|
};
|
||||||
|
const prevPickingHours = prevPicking.pickingTimeSeconds / 3600;
|
||||||
|
|
||||||
|
// Previous shipping data
|
||||||
|
const prevShippingWhere = previousRange.whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||||
|
const [prevShippingRows] = await connection.execute(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
WHERE ${prevShippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)`,
|
||||||
|
previousRange.params
|
||||||
|
);
|
||||||
|
const prevShipping = prevShippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
||||||
|
|
||||||
|
// Calculate previous period FTE and productivity
|
||||||
|
const prevFte = calculateFTE(prevTotalHours, previousRange.start || periodStart, previousRange.end || periodEnd);
|
||||||
|
const prevOrdersPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevProductiveHours : 0;
|
||||||
|
const prevPiecesPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevProductiveHours : 0;
|
||||||
|
|
||||||
|
previousTotals = {
|
||||||
|
hours: prevTotalHours,
|
||||||
|
productiveHours: prevProductiveHours,
|
||||||
|
activeEmployees: prevActiveEmployees,
|
||||||
|
fte: prevFte,
|
||||||
|
ordersPicked: parseInt(prevPicking.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(prevPicking.piecesPicked || 0),
|
||||||
|
pickingHours: prevPickingHours,
|
||||||
|
ordersShipped: parseInt(prevShipping.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(prevShipping.piecesShipped || 0),
|
||||||
|
ordersPerHour: prevOrdersPerHour,
|
||||||
|
piecesPerHour: prevPiecesPerHour,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate productivity metrics for comparison
|
||||||
|
const currentOrdersPerHour = totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0;
|
||||||
|
const currentPiecesPerHour = totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0;
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
hours: calculateComparison(totalHours, prevTotalHours),
|
||||||
|
productiveHours: calculateComparison(totalProductiveHours, prevProductiveHours),
|
||||||
|
activeEmployees: calculateComparison(activeEmployees, prevActiveEmployees),
|
||||||
|
fte: calculateComparison(fte, prevFte),
|
||||||
|
ordersPicked: calculateComparison(totalOrdersPicked, parseInt(prevPicking.ordersPicked || 0)),
|
||||||
|
piecesPicked: calculateComparison(totalPiecesPicked, parseInt(prevPicking.piecesPicked || 0)),
|
||||||
|
ordersShipped: calculateComparison(parseInt(shipping.ordersShipped || 0), parseInt(prevShipping.ordersShipped || 0)),
|
||||||
|
piecesShipped: calculateComparison(parseInt(shipping.piecesShipped || 0), parseInt(prevShipping.piecesShipped || 0)),
|
||||||
|
ordersPerHour: calculateComparison(currentOrdersPerHour, prevOrdersPerHour),
|
||||||
|
piecesPerHour: calculateComparison(currentPiecesPerHour, prevPiecesPerHour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate efficiency (picking time vs productive hours)
|
||||||
|
const pickingEfficiency = totalProductiveHours > 0 ? (totalPickingHours / totalProductiveHours) * 100 : 0;
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
dateRange,
|
||||||
|
totals: {
|
||||||
|
// Time metrics
|
||||||
|
hours: totalHours,
|
||||||
|
breakHours: totalBreakHours,
|
||||||
|
productiveHours: totalProductiveHours,
|
||||||
|
pickingHours: totalPickingHours,
|
||||||
|
|
||||||
|
// Employee metrics
|
||||||
|
activeEmployees,
|
||||||
|
fte,
|
||||||
|
weeksInPeriod,
|
||||||
|
|
||||||
|
// Picking metrics
|
||||||
|
ordersPicked: totalOrdersPicked,
|
||||||
|
piecesPicked: totalPiecesPicked,
|
||||||
|
ticketCount: totalTickets,
|
||||||
|
|
||||||
|
// Shipping metrics
|
||||||
|
ordersShipped: parseInt(shipping.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(shipping.piecesShipped || 0),
|
||||||
|
|
||||||
|
// Calculated metrics - standardized to weekly
|
||||||
|
hoursPerWeek: weeksInPeriod > 0 ? totalHours / weeksInPeriod : 0,
|
||||||
|
hoursPerEmployeePerWeek: activeEmployees > 0 && weeksInPeriod > 0
|
||||||
|
? (totalHours / activeEmployees) / weeksInPeriod
|
||||||
|
: 0,
|
||||||
|
|
||||||
|
// Productivity metrics (uses productive hours - excludes breaks)
|
||||||
|
ordersPerHour: totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0,
|
||||||
|
piecesPerHour: totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0,
|
||||||
|
|
||||||
|
// Picking speed from database (more accurate, only counts picking time)
|
||||||
|
avgPickingSpeed,
|
||||||
|
|
||||||
|
// Efficiency metrics
|
||||||
|
pickingEfficiency,
|
||||||
|
},
|
||||||
|
previousTotals,
|
||||||
|
comparison,
|
||||||
|
byEmployee: {
|
||||||
|
hours: enrichedEmployeeHours,
|
||||||
|
picking: pickingByEmployee,
|
||||||
|
shipping: shippingByEmployee,
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { response, release };
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[EMPLOYEE-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, release } = result;
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
|
||||||
|
console.log(`[EMPLOYEE-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
|
res.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /employee-metrics:', error);
|
||||||
|
console.log(`[EMPLOYEE-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
await connection.execute('SELECT 1 as test');
|
||||||
|
release();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pool: getPoolStatus(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function calculateComparison(currentValue, previousValue) {
|
||||||
|
if (typeof previousValue !== 'number') {
|
||||||
|
return { absolute: null, percentage: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
|
||||||
|
const percentage =
|
||||||
|
absolute !== null && previousValue !== 0
|
||||||
|
? (absolute / Math.abs(previousValue)) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { absolute, percentage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||||
|
if (timeRange && timeRange !== 'custom') {
|
||||||
|
const prevTimeRange = getPreviousTimeRange(timeRange);
|
||||||
|
if (!prevTimeRange || prevTimeRange === timeRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getTimeRangeConditions(prevTimeRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate;
|
||||||
|
if (!hasCustomDates) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = end.getTime() - start.getTime();
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevEnd = new Date(start.getTime() - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
|
||||||
|
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousTimeRange(timeRange) {
|
||||||
|
const map = {
|
||||||
|
today: 'yesterday',
|
||||||
|
thisWeek: 'lastWeek',
|
||||||
|
thisMonth: 'lastMonth',
|
||||||
|
last7days: 'previous7days',
|
||||||
|
last30days: 'previous30days',
|
||||||
|
last90days: 'previous90days',
|
||||||
|
yesterday: 'twoDaysAgo'
|
||||||
|
};
|
||||||
|
return map[timeRange] || timeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||||
|
const {
|
||||||
|
getTimeRangeConditions,
|
||||||
|
} = require('../utils/timeUtils');
|
||||||
|
|
||||||
|
const TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
// Main operations metrics endpoint - focused on picking and shipping
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[OPERATIONS-METRICS] Starting request for timeRange: ${req.query.timeRange}`);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mainOperation = async () => {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
console.log(`[OPERATIONS-METRICS] Getting DB connection...`);
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
|
// Query for picking tickets - using subquery to avoid duplication from bucket join
|
||||||
|
// Ship-together orders: only count main orders (is_sub = 0 or NULL), not sub-orders
|
||||||
|
const pickingWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
|
||||||
|
// First get picking ticket stats without the bucket join (to avoid duplication)
|
||||||
|
const pickingStatsQuery = `
|
||||||
|
SELECT
|
||||||
|
pt.createdby as employeeId,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname,
|
||||||
|
COUNT(DISTINCT pt.pickingid) as ticketCount,
|
||||||
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
||||||
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds,
|
||||||
|
AVG(NULLIF(pt.picking_speed, 0)) as avgPickingSpeed
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN employees e ON pt.createdby = e.employeeid
|
||||||
|
WHERE ${pickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY pt.createdby, e.firstname, e.lastname
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Separate query for order counts (needs bucket join for ship-together handling)
|
||||||
|
const orderCountQuery = `
|
||||||
|
SELECT
|
||||||
|
pt.createdby as employeeId,
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${pickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY pt.createdby
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [[pickingStatsRows], [orderCountRows]] = await Promise.all([
|
||||||
|
connection.execute(pickingStatsQuery, params),
|
||||||
|
connection.execute(orderCountQuery, params)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge the results
|
||||||
|
const orderCountMap = new Map();
|
||||||
|
orderCountRows.forEach(row => {
|
||||||
|
orderCountMap.set(row.employeeId, parseInt(row.ordersPicked || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate picking totals
|
||||||
|
let totalOrdersPicked = 0;
|
||||||
|
let totalPiecesPicked = 0;
|
||||||
|
let totalTickets = 0;
|
||||||
|
let totalPickingTimeSeconds = 0;
|
||||||
|
let pickingSpeedSum = 0;
|
||||||
|
let pickingSpeedCount = 0;
|
||||||
|
|
||||||
|
const pickingByEmployee = pickingStatsRows.map(row => {
|
||||||
|
const ordersPicked = orderCountMap.get(row.employeeId) || 0;
|
||||||
|
totalOrdersPicked += ordersPicked;
|
||||||
|
totalPiecesPicked += parseInt(row.piecesPicked || 0);
|
||||||
|
totalTickets += parseInt(row.ticketCount || 0);
|
||||||
|
totalPickingTimeSeconds += parseInt(row.pickingTimeSeconds || 0);
|
||||||
|
if (row.avgPickingSpeed && row.avgPickingSpeed > 0) {
|
||||||
|
pickingSpeedSum += parseFloat(row.avgPickingSpeed);
|
||||||
|
pickingSpeedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const empPickingHours = parseInt(row.pickingTimeSeconds || 0) / 3600;
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeId: row.employeeId,
|
||||||
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeId}`,
|
||||||
|
ticketCount: parseInt(row.ticketCount || 0),
|
||||||
|
ordersPicked,
|
||||||
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
||||||
|
pickingHours: empPickingHours,
|
||||||
|
avgPickingSpeed: row.avgPickingSpeed ? parseFloat(row.avgPickingSpeed) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPickingHours = totalPickingTimeSeconds / 3600;
|
||||||
|
const avgPickingSpeed = pickingSpeedCount > 0 ? pickingSpeedSum / pickingSpeedCount : 0;
|
||||||
|
|
||||||
|
// Query for shipped orders - totals
|
||||||
|
// Ship-together orders: only count main orders (order_type != 8 for sub-orders)
|
||||||
|
const shippingWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||||
|
|
||||||
|
const shippingQuery = `
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
WHERE ${shippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [shippingRows] = await connection.execute(shippingQuery, params);
|
||||||
|
const shipping = shippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
||||||
|
|
||||||
|
// Query for shipped orders by employee
|
||||||
|
const shippingByEmployeeQuery = `
|
||||||
|
SELECT
|
||||||
|
e.employeeid,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname,
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
JOIN employees e ON o.stats_cid_shipped = e.cid
|
||||||
|
WHERE ${shippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
GROUP BY e.employeeid, e.firstname, e.lastname
|
||||||
|
ORDER BY ordersShipped DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [shippingByEmployeeRows] = await connection.execute(shippingByEmployeeQuery, params);
|
||||||
|
const shippingByEmployee = shippingByEmployeeRows.map(row => ({
|
||||||
|
employeeId: row.employeeid,
|
||||||
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeid}`,
|
||||||
|
ordersShipped: parseInt(row.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(row.piecesShipped || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate period dates
|
||||||
|
let periodStart, periodEnd;
|
||||||
|
if (dateRange?.start) {
|
||||||
|
periodStart = new Date(dateRange.start);
|
||||||
|
} else if (params[0]) {
|
||||||
|
periodStart = new Date(params[0]);
|
||||||
|
} else {
|
||||||
|
periodStart = new Date();
|
||||||
|
periodStart.setDate(periodStart.getDate() - 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateRange?.end) {
|
||||||
|
periodEnd = new Date(dateRange.end);
|
||||||
|
} else if (params[1]) {
|
||||||
|
periodEnd = new Date(params[1]);
|
||||||
|
} else {
|
||||||
|
periodEnd = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate productivity (orders/pieces per picking hour)
|
||||||
|
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
|
||||||
|
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
|
||||||
|
|
||||||
|
// Get daily trend data for picking
|
||||||
|
// Use DATE_FORMAT to get date string in Eastern timezone
|
||||||
|
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
||||||
|
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
const pickingTrendQuery = `
|
||||||
|
SELECT
|
||||||
|
pt_agg.date,
|
||||||
|
COALESCE(order_counts.ordersPicked, 0) as ordersPicked,
|
||||||
|
pt_agg.piecesPicked
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||||
|
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
WHERE ${pickingTrendWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||||
|
) pt_agg
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${pickingTrendWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL
|
||||||
|
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||||
|
) order_counts ON pt_agg.date = order_counts.date
|
||||||
|
ORDER BY pt_agg.date
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get shipping trend data
|
||||||
|
const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||||
|
const shippingTrendQuery = `
|
||||||
|
SELECT
|
||||||
|
DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
WHERE ${shippingTrendWhere}
|
||||||
|
AND o.order_status IN (100, 92)
|
||||||
|
GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d')
|
||||||
|
ORDER BY date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([
|
||||||
|
connection.execute(pickingTrendQuery, [...params, ...params]),
|
||||||
|
connection.execute(shippingTrendQuery, params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create maps for trend data
|
||||||
|
const pickingByDate = new Map();
|
||||||
|
pickingTrendRows.forEach(row => {
|
||||||
|
const date = String(row.date);
|
||||||
|
pickingByDate.set(date, {
|
||||||
|
ordersPicked: parseInt(row.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const shippingByDate = new Map();
|
||||||
|
shippingTrendRows.forEach(row => {
|
||||||
|
const date = String(row.date);
|
||||||
|
shippingByDate.set(date, {
|
||||||
|
ordersShipped: parseInt(row.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(row.piecesShipped || 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate all dates in the period range for complete trend data
|
||||||
|
const allDatesInRange = [];
|
||||||
|
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
|
||||||
|
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
|
||||||
|
|
||||||
|
let currentDt = startDt;
|
||||||
|
while (currentDt <= endDt) {
|
||||||
|
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
|
||||||
|
currentDt = currentDt.plus({ days: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trend data for all dates in range
|
||||||
|
const trend = allDatesInRange.map(date => {
|
||||||
|
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
|
||||||
|
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 0 };
|
||||||
|
|
||||||
|
// Parse date string in Eastern timezone to get proper ISO timestamp
|
||||||
|
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timestamp: dateDt.toISO(),
|
||||||
|
ordersPicked: picking.ordersPicked,
|
||||||
|
piecesPicked: picking.piecesPicked,
|
||||||
|
ordersShipped: shippingData.ordersShipped,
|
||||||
|
piecesShipped: shippingData.piecesShipped,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get previous period data for comparison
|
||||||
|
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
|
||||||
|
let comparison = null;
|
||||||
|
let previousTotals = null;
|
||||||
|
|
||||||
|
if (previousRange) {
|
||||||
|
// Previous picking data
|
||||||
|
const prevPickingWhere = previousRange.whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||||
|
|
||||||
|
const [[prevPickingStatsRows], [prevOrderCountRows]] = await Promise.all([
|
||||||
|
connection.execute(
|
||||||
|
`SELECT
|
||||||
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
||||||
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds
|
||||||
|
FROM picking_ticket pt
|
||||||
|
WHERE ${prevPickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL`,
|
||||||
|
previousRange.params
|
||||||
|
),
|
||||||
|
connection.execute(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
||||||
|
FROM picking_ticket pt
|
||||||
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
||||||
|
WHERE ${prevPickingWhere}
|
||||||
|
AND pt.closeddate IS NOT NULL`,
|
||||||
|
previousRange.params
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prevPickingStats = prevPickingStatsRows[0] || { piecesPicked: 0, pickingTimeSeconds: 0 };
|
||||||
|
const prevOrderCount = prevOrderCountRows[0] || { ordersPicked: 0 };
|
||||||
|
const prevPicking = {
|
||||||
|
ordersPicked: parseInt(prevOrderCount.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(prevPickingStats.piecesPicked || 0),
|
||||||
|
pickingTimeSeconds: parseInt(prevPickingStats.pickingTimeSeconds || 0)
|
||||||
|
};
|
||||||
|
const prevPickingHours = prevPicking.pickingTimeSeconds / 3600;
|
||||||
|
|
||||||
|
// Previous shipping data
|
||||||
|
const prevShippingWhere = previousRange.whereClause.replace(/date_placed/g, 'o.date_shipped');
|
||||||
|
const [prevShippingRows] = await connection.execute(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
||||||
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
||||||
|
FROM _order o
|
||||||
|
WHERE ${prevShippingWhere}
|
||||||
|
AND o.order_status IN (100, 92)`,
|
||||||
|
previousRange.params
|
||||||
|
);
|
||||||
|
const prevShipping = prevShippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
||||||
|
|
||||||
|
// Calculate previous productivity
|
||||||
|
const prevOrdersPerHour = prevPickingHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevPickingHours : 0;
|
||||||
|
const prevPiecesPerHour = prevPickingHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevPickingHours : 0;
|
||||||
|
|
||||||
|
previousTotals = {
|
||||||
|
ordersPicked: parseInt(prevPicking.ordersPicked || 0),
|
||||||
|
piecesPicked: parseInt(prevPicking.piecesPicked || 0),
|
||||||
|
pickingHours: prevPickingHours,
|
||||||
|
ordersShipped: parseInt(prevShipping.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(prevShipping.piecesShipped || 0),
|
||||||
|
ordersPerHour: prevOrdersPerHour,
|
||||||
|
piecesPerHour: prevPiecesPerHour,
|
||||||
|
};
|
||||||
|
|
||||||
|
comparison = {
|
||||||
|
ordersPicked: calculateComparison(totalOrdersPicked, parseInt(prevPicking.ordersPicked || 0)),
|
||||||
|
piecesPicked: calculateComparison(totalPiecesPicked, parseInt(prevPicking.piecesPicked || 0)),
|
||||||
|
ordersShipped: calculateComparison(parseInt(shipping.ordersShipped || 0), parseInt(prevShipping.ordersShipped || 0)),
|
||||||
|
piecesShipped: calculateComparison(parseInt(shipping.piecesShipped || 0), parseInt(prevShipping.piecesShipped || 0)),
|
||||||
|
ordersPerHour: calculateComparison(ordersPerHour, prevOrdersPerHour),
|
||||||
|
piecesPerHour: calculateComparison(piecesPerHour, prevPiecesPerHour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
dateRange,
|
||||||
|
totals: {
|
||||||
|
// Picking metrics
|
||||||
|
ordersPicked: totalOrdersPicked,
|
||||||
|
piecesPicked: totalPiecesPicked,
|
||||||
|
ticketCount: totalTickets,
|
||||||
|
pickingHours: totalPickingHours,
|
||||||
|
|
||||||
|
// Shipping metrics
|
||||||
|
ordersShipped: parseInt(shipping.ordersShipped || 0),
|
||||||
|
piecesShipped: parseInt(shipping.piecesShipped || 0),
|
||||||
|
|
||||||
|
// Productivity metrics
|
||||||
|
ordersPerHour,
|
||||||
|
piecesPerHour,
|
||||||
|
avgPickingSpeed,
|
||||||
|
},
|
||||||
|
previousTotals,
|
||||||
|
comparison,
|
||||||
|
byEmployee: {
|
||||||
|
picking: pickingByEmployee,
|
||||||
|
shipping: shippingByEmployee,
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { response, release };
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, release } = result;
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
|
||||||
|
console.log(`[OPERATIONS-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
|
res.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /operations-metrics:', error);
|
||||||
|
console.log(`[OPERATIONS-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
await connection.execute('SELECT 1 as test');
|
||||||
|
release();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pool: getPoolStatus(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function calculateComparison(currentValue, previousValue) {
|
||||||
|
if (typeof previousValue !== 'number') {
|
||||||
|
return { absolute: null, percentage: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
|
||||||
|
const percentage =
|
||||||
|
absolute !== null && previousValue !== 0
|
||||||
|
? (absolute / Math.abs(previousValue)) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { absolute, percentage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
||||||
|
if (timeRange && timeRange !== 'custom') {
|
||||||
|
const prevTimeRange = getPreviousTimeRange(timeRange);
|
||||||
|
if (!prevTimeRange || prevTimeRange === timeRange) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getTimeRangeConditions(prevTimeRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate;
|
||||||
|
if (!hasCustomDates) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = end.getTime() - start.getTime();
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevEnd = new Date(start.getTime() - 1);
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
||||||
|
|
||||||
|
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousTimeRange(timeRange) {
|
||||||
|
const map = {
|
||||||
|
today: 'yesterday',
|
||||||
|
thisWeek: 'lastWeek',
|
||||||
|
thisMonth: 'lastMonth',
|
||||||
|
last7days: 'previous7days',
|
||||||
|
last30days: 'previous30days',
|
||||||
|
last90days: 'previous90days',
|
||||||
|
yesterday: 'twoDaysAgo'
|
||||||
|
};
|
||||||
|
return map[timeRange] || timeRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
||||||
|
|
||||||
|
const TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
// Punch types from the database
|
||||||
|
const PUNCH_TYPES = {
|
||||||
|
OUT: 0,
|
||||||
|
IN: 1,
|
||||||
|
BREAK_START: 2,
|
||||||
|
BREAK_END: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Standard hours for overtime calculation (40 hours per week)
|
||||||
|
const STANDARD_WEEKLY_HOURS = 40;
|
||||||
|
|
||||||
|
// Reference pay period start date (January 25, 2026 is a Sunday, first day of a pay period)
|
||||||
|
const PAY_PERIOD_REFERENCE = DateTime.fromObject(
|
||||||
|
{ year: 2026, month: 1, day: 25 },
|
||||||
|
{ zone: TIMEZONE }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the pay period that contains a given date
|
||||||
|
* Pay periods are 14 days starting on Sunday
|
||||||
|
* @param {DateTime} date - The date to find the pay period for
|
||||||
|
* @returns {{ start: DateTime, end: DateTime, week1: { start: DateTime, end: DateTime }, week2: { start: DateTime, end: DateTime } }}
|
||||||
|
*/
|
||||||
|
function getPayPeriodForDate(date) {
|
||||||
|
const dt = DateTime.isDateTime(date) ? date : DateTime.fromJSDate(date, { zone: TIMEZONE });
|
||||||
|
|
||||||
|
// Calculate days since reference
|
||||||
|
const daysSinceReference = Math.floor(dt.diff(PAY_PERIOD_REFERENCE, 'days').days);
|
||||||
|
|
||||||
|
// Find which pay period this falls into (can be negative for dates before reference)
|
||||||
|
const payPeriodIndex = Math.floor(daysSinceReference / 14);
|
||||||
|
|
||||||
|
// Calculate the start of this pay period
|
||||||
|
const start = PAY_PERIOD_REFERENCE.plus({ days: payPeriodIndex * 14 }).startOf('day');
|
||||||
|
const end = start.plus({ days: 13 }).endOf('day');
|
||||||
|
|
||||||
|
// Week 1: Sunday through Saturday
|
||||||
|
const week1Start = start;
|
||||||
|
const week1End = start.plus({ days: 6 }).endOf('day');
|
||||||
|
|
||||||
|
// Week 2: Sunday through Saturday
|
||||||
|
const week2Start = start.plus({ days: 7 }).startOf('day');
|
||||||
|
const week2End = end;
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
week1: { start: week1Start, end: week1End },
|
||||||
|
week2: { start: week2Start, end: week2End },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current pay period
|
||||||
|
*/
|
||||||
|
function getCurrentPayPeriod() {
|
||||||
|
return getPayPeriodForDate(DateTime.now().setZone(TIMEZONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to previous or next pay period
|
||||||
|
* @param {DateTime} currentStart - Current pay period start
|
||||||
|
* @param {number} offset - Number of pay periods to move (negative for previous)
|
||||||
|
*/
|
||||||
|
function navigatePayPeriod(currentStart, offset) {
|
||||||
|
const newStart = currentStart.plus({ days: offset * 14 });
|
||||||
|
return getPayPeriodForDate(newStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate working hours from timeclock entries, broken down by week
|
||||||
|
* @param {Array} punches - Timeclock punch entries
|
||||||
|
* @param {Object} payPeriod - Pay period with week boundaries
|
||||||
|
*/
|
||||||
|
function calculateHoursByWeek(punches, payPeriod) {
|
||||||
|
// Group by employee
|
||||||
|
const byEmployee = new Map();
|
||||||
|
|
||||||
|
punches.forEach(punch => {
|
||||||
|
if (!byEmployee.has(punch.EmployeeID)) {
|
||||||
|
byEmployee.set(punch.EmployeeID, {
|
||||||
|
employeeId: punch.EmployeeID,
|
||||||
|
firstname: punch.firstname || '',
|
||||||
|
lastname: punch.lastname || '',
|
||||||
|
punches: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
byEmployee.get(punch.EmployeeID).punches.push(punch);
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeeResults = [];
|
||||||
|
let totalHours = 0;
|
||||||
|
let totalBreakHours = 0;
|
||||||
|
let totalOvertimeHours = 0;
|
||||||
|
let totalRegularHours = 0;
|
||||||
|
let week1TotalHours = 0;
|
||||||
|
let week1TotalOvertime = 0;
|
||||||
|
let week2TotalHours = 0;
|
||||||
|
let week2TotalOvertime = 0;
|
||||||
|
|
||||||
|
byEmployee.forEach((employeeData) => {
|
||||||
|
// Sort punches by timestamp
|
||||||
|
employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
|
||||||
|
|
||||||
|
// Calculate hours for each week
|
||||||
|
const week1Punches = employeeData.punches.filter(p => {
|
||||||
|
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
|
||||||
|
return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
const week2Punches = employeeData.punches.filter(p => {
|
||||||
|
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
|
||||||
|
return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
const week1Hours = calculateHoursFromPunches(week1Punches);
|
||||||
|
const week2Hours = calculateHoursFromPunches(week2Punches);
|
||||||
|
|
||||||
|
// Calculate overtime per week (anything over 40 hours)
|
||||||
|
const week1Overtime = Math.max(0, week1Hours.hours - STANDARD_WEEKLY_HOURS);
|
||||||
|
const week2Overtime = Math.max(0, week2Hours.hours - STANDARD_WEEKLY_HOURS);
|
||||||
|
const week1Regular = week1Hours.hours - week1Overtime;
|
||||||
|
const week2Regular = week2Hours.hours - week2Overtime;
|
||||||
|
|
||||||
|
const employeeTotal = week1Hours.hours + week2Hours.hours;
|
||||||
|
const employeeBreaks = week1Hours.breakHours + week2Hours.breakHours;
|
||||||
|
const employeeOvertime = week1Overtime + week2Overtime;
|
||||||
|
const employeeRegular = employeeTotal - employeeOvertime;
|
||||||
|
|
||||||
|
totalHours += employeeTotal;
|
||||||
|
totalBreakHours += employeeBreaks;
|
||||||
|
totalOvertimeHours += employeeOvertime;
|
||||||
|
totalRegularHours += employeeRegular;
|
||||||
|
week1TotalHours += week1Hours.hours;
|
||||||
|
week1TotalOvertime += week1Overtime;
|
||||||
|
week2TotalHours += week2Hours.hours;
|
||||||
|
week2TotalOvertime += week2Overtime;
|
||||||
|
|
||||||
|
employeeResults.push({
|
||||||
|
employeeId: employeeData.employeeId,
|
||||||
|
name: `${employeeData.firstname} ${employeeData.lastname}`.trim() || `Employee ${employeeData.employeeId}`,
|
||||||
|
week1Hours: week1Hours.hours,
|
||||||
|
week1BreakHours: week1Hours.breakHours,
|
||||||
|
week1Overtime,
|
||||||
|
week1Regular,
|
||||||
|
week2Hours: week2Hours.hours,
|
||||||
|
week2BreakHours: week2Hours.breakHours,
|
||||||
|
week2Overtime,
|
||||||
|
week2Regular,
|
||||||
|
totalHours: employeeTotal,
|
||||||
|
totalBreakHours: employeeBreaks,
|
||||||
|
overtimeHours: employeeOvertime,
|
||||||
|
regularHours: employeeRegular,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by total hours descending
|
||||||
|
employeeResults.sort((a, b) => b.totalHours - a.totalHours);
|
||||||
|
|
||||||
|
return {
|
||||||
|
byEmployee: employeeResults,
|
||||||
|
totals: {
|
||||||
|
hours: totalHours,
|
||||||
|
breakHours: totalBreakHours,
|
||||||
|
overtimeHours: totalOvertimeHours,
|
||||||
|
regularHours: totalRegularHours,
|
||||||
|
activeEmployees: employeeResults.filter(e => e.totalHours > 0).length,
|
||||||
|
},
|
||||||
|
byWeek: [
|
||||||
|
{
|
||||||
|
week: 1,
|
||||||
|
start: payPeriod.week1.start.toISODate(),
|
||||||
|
end: payPeriod.week1.end.toISODate(),
|
||||||
|
hours: week1TotalHours,
|
||||||
|
overtime: week1TotalOvertime,
|
||||||
|
regular: week1TotalHours - week1TotalOvertime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
week: 2,
|
||||||
|
start: payPeriod.week2.start.toISODate(),
|
||||||
|
end: payPeriod.week2.end.toISODate(),
|
||||||
|
hours: week2TotalHours,
|
||||||
|
overtime: week2TotalOvertime,
|
||||||
|
regular: week2TotalHours - week2TotalOvertime,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate hours from a set of punches
|
||||||
|
*/
|
||||||
|
function calculateHoursFromPunches(punches) {
|
||||||
|
let hours = 0;
|
||||||
|
let breakHours = 0;
|
||||||
|
let currentIn = null;
|
||||||
|
let breakStart = null;
|
||||||
|
|
||||||
|
punches.forEach(punch => {
|
||||||
|
const punchTime = new Date(punch.TimeStamp);
|
||||||
|
|
||||||
|
switch (punch.PunchType) {
|
||||||
|
case PUNCH_TYPES.IN:
|
||||||
|
currentIn = punchTime;
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.OUT:
|
||||||
|
if (currentIn) {
|
||||||
|
hours += (punchTime - currentIn) / (1000 * 60 * 60);
|
||||||
|
currentIn = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.BREAK_START:
|
||||||
|
breakStart = punchTime;
|
||||||
|
break;
|
||||||
|
case PUNCH_TYPES.BREAK_END:
|
||||||
|
if (breakStart) {
|
||||||
|
breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
|
||||||
|
breakStart = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { hours, breakHours };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period)
|
||||||
|
* @param {number} totalHours - Total hours worked
|
||||||
|
* @param {number} elapsedFraction - Fraction of the period elapsed (0-1). Defaults to 1 for complete periods.
|
||||||
|
*/
|
||||||
|
function calculateFTE(totalHours, elapsedFraction = 1) {
|
||||||
|
const fullTimePeriodHours = STANDARD_WEEKLY_HOURS * 2; // 80 hours for 2 weeks
|
||||||
|
const proratedHours = fullTimePeriodHours * elapsedFraction;
|
||||||
|
return proratedHours > 0 ? totalHours / proratedHours : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main payroll metrics endpoint
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log(`[PAYROLL-METRICS] Starting request`);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mainOperation = async () => {
|
||||||
|
const { payPeriodStart, navigate } = req.query;
|
||||||
|
|
||||||
|
let payPeriod;
|
||||||
|
|
||||||
|
if (payPeriodStart) {
|
||||||
|
// Parse the provided start date
|
||||||
|
const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE });
|
||||||
|
if (!startDate.isValid) {
|
||||||
|
return res.status(400).json({ error: 'Invalid payPeriodStart date format' });
|
||||||
|
}
|
||||||
|
payPeriod = getPayPeriodForDate(startDate);
|
||||||
|
} else {
|
||||||
|
// Default to current pay period
|
||||||
|
payPeriod = getCurrentPayPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation if requested
|
||||||
|
if (navigate) {
|
||||||
|
const offset = parseInt(navigate, 10);
|
||||||
|
if (!isNaN(offset)) {
|
||||||
|
payPeriod = navigatePayPeriod(payPeriod.start, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PAYROLL-METRICS] Getting DB connection...`);
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
|
||||||
|
// Build query for the pay period
|
||||||
|
const periodStart = payPeriod.start.toJSDate();
|
||||||
|
const periodEnd = payPeriod.end.toJSDate();
|
||||||
|
|
||||||
|
const timeclockQuery = `
|
||||||
|
SELECT
|
||||||
|
tc.EmployeeID,
|
||||||
|
tc.TimeStamp,
|
||||||
|
tc.PunchType,
|
||||||
|
e.firstname,
|
||||||
|
e.lastname
|
||||||
|
FROM timeclock tc
|
||||||
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
||||||
|
WHERE tc.TimeStamp >= ? AND tc.TimeStamp <= ?
|
||||||
|
AND e.hidden = 0
|
||||||
|
AND e.disabled = 0
|
||||||
|
ORDER BY tc.EmployeeID, tc.TimeStamp
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [timeclockRows] = await connection.execute(timeclockQuery, [periodStart, periodEnd]);
|
||||||
|
|
||||||
|
// Calculate hours with week breakdown
|
||||||
|
const hoursData = calculateHoursByWeek(timeclockRows, payPeriod);
|
||||||
|
|
||||||
|
// Calculate FTE — prorate for in-progress periods so the value reflects
|
||||||
|
// the pace employees are on rather than raw hours / 80
|
||||||
|
let elapsedFraction = 1;
|
||||||
|
if (isCurrentPayPeriod(payPeriod)) {
|
||||||
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
const elapsedDays = Math.max(1, Math.ceil(now.diff(payPeriod.start, 'days').days));
|
||||||
|
elapsedFraction = Math.min(1, elapsedDays / 14);
|
||||||
|
}
|
||||||
|
const fte = calculateFTE(hoursData.totals.hours, elapsedFraction);
|
||||||
|
const activeEmployees = hoursData.totals.activeEmployees;
|
||||||
|
const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0;
|
||||||
|
|
||||||
|
// Get previous pay period data for comparison
|
||||||
|
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
|
||||||
|
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
|
||||||
|
prevPayPeriod.start.toJSDate(),
|
||||||
|
prevPayPeriod.end.toJSDate(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
|
||||||
|
const prevFte = calculateFTE(prevHoursData.totals.hours);
|
||||||
|
|
||||||
|
// Calculate comparisons
|
||||||
|
const comparison = {
|
||||||
|
hours: calculateComparison(hoursData.totals.hours, prevHoursData.totals.hours),
|
||||||
|
overtimeHours: calculateComparison(hoursData.totals.overtimeHours, prevHoursData.totals.overtimeHours),
|
||||||
|
fte: calculateComparison(fte, prevFte),
|
||||||
|
activeEmployees: calculateComparison(hoursData.totals.activeEmployees, prevHoursData.totals.activeEmployees),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
payPeriod: {
|
||||||
|
start: payPeriod.start.toISODate(),
|
||||||
|
end: payPeriod.end.toISODate(),
|
||||||
|
label: formatPayPeriodLabel(payPeriod),
|
||||||
|
week1: {
|
||||||
|
start: payPeriod.week1.start.toISODate(),
|
||||||
|
end: payPeriod.week1.end.toISODate(),
|
||||||
|
label: formatWeekLabel(payPeriod.week1),
|
||||||
|
},
|
||||||
|
week2: {
|
||||||
|
start: payPeriod.week2.start.toISODate(),
|
||||||
|
end: payPeriod.week2.end.toISODate(),
|
||||||
|
label: formatWeekLabel(payPeriod.week2),
|
||||||
|
},
|
||||||
|
isCurrent: isCurrentPayPeriod(payPeriod),
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
hours: hoursData.totals.hours,
|
||||||
|
breakHours: hoursData.totals.breakHours,
|
||||||
|
overtimeHours: hoursData.totals.overtimeHours,
|
||||||
|
regularHours: hoursData.totals.regularHours,
|
||||||
|
activeEmployees,
|
||||||
|
fte,
|
||||||
|
avgHoursPerEmployee,
|
||||||
|
},
|
||||||
|
previousTotals: {
|
||||||
|
hours: prevHoursData.totals.hours,
|
||||||
|
overtimeHours: prevHoursData.totals.overtimeHours,
|
||||||
|
activeEmployees: prevHoursData.totals.activeEmployees,
|
||||||
|
fte: prevFte,
|
||||||
|
},
|
||||||
|
comparison,
|
||||||
|
byEmployee: hoursData.byEmployee,
|
||||||
|
byWeek: hoursData.byWeek,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { response, release };
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, release } = result;
|
||||||
|
|
||||||
|
if (release) release();
|
||||||
|
|
||||||
|
console.log(`[PAYROLL-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
|
res.json(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /payroll-metrics:', error);
|
||||||
|
console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get pay period info endpoint (for navigation without full data)
|
||||||
|
router.get('/period-info', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { payPeriodStart, navigate } = req.query;
|
||||||
|
|
||||||
|
let payPeriod;
|
||||||
|
|
||||||
|
if (payPeriodStart) {
|
||||||
|
const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE });
|
||||||
|
if (!startDate.isValid) {
|
||||||
|
return res.status(400).json({ error: 'Invalid payPeriodStart date format' });
|
||||||
|
}
|
||||||
|
payPeriod = getPayPeriodForDate(startDate);
|
||||||
|
} else {
|
||||||
|
payPeriod = getCurrentPayPeriod();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigate) {
|
||||||
|
const offset = parseInt(navigate, 10);
|
||||||
|
if (!isNaN(offset)) {
|
||||||
|
payPeriod = navigatePayPeriod(payPeriod.start, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
payPeriod: {
|
||||||
|
start: payPeriod.start.toISODate(),
|
||||||
|
end: payPeriod.end.toISODate(),
|
||||||
|
label: formatPayPeriodLabel(payPeriod),
|
||||||
|
week1: {
|
||||||
|
start: payPeriod.week1.start.toISODate(),
|
||||||
|
end: payPeriod.week1.end.toISODate(),
|
||||||
|
label: formatWeekLabel(payPeriod.week1),
|
||||||
|
},
|
||||||
|
week2: {
|
||||||
|
start: payPeriod.week2.start.toISODate(),
|
||||||
|
end: payPeriod.week2.end.toISODate(),
|
||||||
|
label: formatWeekLabel(payPeriod.week2),
|
||||||
|
},
|
||||||
|
isCurrent: isCurrentPayPeriod(payPeriod),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /payroll-metrics/period-info:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
router.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
await connection.execute('SELECT 1 as test');
|
||||||
|
release();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
pool: getPoolStatus(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'unhealthy',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function calculateComparison(currentValue, previousValue) {
|
||||||
|
if (typeof previousValue !== 'number') {
|
||||||
|
return { absolute: null, percentage: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
|
||||||
|
const percentage =
|
||||||
|
absolute !== null && previousValue !== 0
|
||||||
|
? (absolute / Math.abs(previousValue)) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { absolute, percentage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayPeriodLabel(payPeriod) {
|
||||||
|
const startStr = payPeriod.start.toFormat('MMM d');
|
||||||
|
const endStr = payPeriod.end.toFormat('MMM d, yyyy');
|
||||||
|
return `${startStr} – ${endStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeekLabel(week) {
|
||||||
|
const startStr = week.start.toFormat('MMM d');
|
||||||
|
const endStr = week.end.toFormat('MMM d');
|
||||||
|
return `${startStr} – ${endStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentPayPeriod(payPeriod) {
|
||||||
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
return now >= payPeriod.start && now <= payPeriod.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getDbConnection, getCachedQuery } = require('../db/connection');
|
||||||
|
|
||||||
|
// Test endpoint to count orders
|
||||||
|
router.get('/order-count', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { connection } = await getDbConnection();
|
||||||
|
|
||||||
|
// Simple query to count orders from _order table
|
||||||
|
const queryFn = async () => {
|
||||||
|
const [rows] = await connection.execute('SELECT COUNT(*) as count FROM _order');
|
||||||
|
return rows[0].count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheKey = 'order-count';
|
||||||
|
const count = await getCachedQuery(cacheKey, 'default', queryFn);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderCount: count,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching order count:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection endpoint
|
||||||
|
router.get('/test-connection', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { connection } = await getDbConnection();
|
||||||
|
|
||||||
|
// Test the connection with a simple query
|
||||||
|
const [rows] = await connection.execute('SELECT 1 as test');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Database connection successful',
|
||||||
|
data: rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing connection:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const morgan = require('morgan');
|
||||||
|
const compression = require('compression');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { closeAllConnections } = require('./db/connection');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.ACOT_PORT || 3012;
|
||||||
|
|
||||||
|
// Create logs directory if it doesn't exist
|
||||||
|
const logDir = path.join(__dirname, 'logs/app');
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a write stream for access logs
|
||||||
|
const accessLogStream = fs.createWriteStream(
|
||||||
|
path.join(logDir, 'access.log'),
|
||||||
|
{ flags: 'a' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(compression());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Logging middleware
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(morgan('combined', { stream: accessLogStream }));
|
||||||
|
} else {
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'healthy',
|
||||||
|
service: 'acot-server',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/acot/test', require('./routes/test'));
|
||||||
|
app.use('/api/acot/events', require('./routes/events'));
|
||||||
|
app.use('/api/acot/discounts', require('./routes/discounts'));
|
||||||
|
app.use('/api/acot/employee-metrics', require('./routes/employee-metrics'));
|
||||||
|
app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics'));
|
||||||
|
app.use('/api/acot/operations-metrics', require('./routes/operations-metrics'));
|
||||||
|
app.use('/api/acot/customers', require('./routes/customers'));
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: process.env.NODE_ENV === 'production'
|
||||||
|
? 'Internal server error'
|
||||||
|
: err.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Route not found'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
console.log(`ACOT Server running on port ${PORT}`);
|
||||||
|
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const gracefulShutdown = async () => {
|
||||||
|
console.log('SIGTERM signal received: closing HTTP server');
|
||||||
|
server.close(async () => {
|
||||||
|
console.log('HTTP server closed');
|
||||||
|
|
||||||
|
// Close database connections
|
||||||
|
try {
|
||||||
|
await closeAllConnections();
|
||||||
|
console.log('Database connections closed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing database connections:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', gracefulShutdown);
|
||||||
|
process.on('SIGINT', gracefulShutdown);
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Shared-secret auth for customer-lookup endpoints that expose PII.
|
||||||
|
// The acot-phone-server sends `x-acot-api-key` on every request; we compare
|
||||||
|
// against ACOT_PHONE_API_KEY from the environment using timing-safe comparison.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function requirePhoneApiKey(req, res, next) {
|
||||||
|
const expected = process.env.ACOT_PHONE_API_KEY;
|
||||||
|
if (!expected) {
|
||||||
|
console.error('ACOT_PHONE_API_KEY not configured; rejecting all requests');
|
||||||
|
return res.status(503).json({ success: false, error: 'auth_not_configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provided = req.get('x-acot-api-key') || '';
|
||||||
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
const providedBuf = Buffer.from(provided);
|
||||||
|
|
||||||
|
if (
|
||||||
|
providedBuf.length !== expectedBuf.length ||
|
||||||
|
!crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ success: false, error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requirePhoneApiKey };
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
const { DateTime } = require('luxon');
|
||||||
|
|
||||||
|
const TIMEZONE = 'America/New_York';
|
||||||
|
const DB_TIMEZONE = 'UTC-05:00';
|
||||||
|
const BUSINESS_DAY_START_HOUR = 1; // 1 AM Eastern
|
||||||
|
const WEEK_START_DAY = 7; // Sunday (Luxon uses 1 = Monday, 7 = Sunday)
|
||||||
|
const DB_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
|
||||||
|
|
||||||
|
const isDateTime = (value) => DateTime.isDateTime(value);
|
||||||
|
|
||||||
|
const ensureDateTime = (value, { zone = TIMEZONE } = {}) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (isDateTime(value)) {
|
||||||
|
return value.setZone(zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return DateTime.fromJSDate(value, { zone });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return DateTime.fromMillis(value, { zone });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
let dt = DateTime.fromISO(value, { zone, setZone: true });
|
||||||
|
if (!dt.isValid) {
|
||||||
|
dt = DateTime.fromSQL(value, { zone });
|
||||||
|
}
|
||||||
|
return dt.isValid ? dt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNow = () => DateTime.now().setZone(TIMEZONE);
|
||||||
|
|
||||||
|
const getDayStart = (input = getNow()) => {
|
||||||
|
const dt = ensureDateTime(input);
|
||||||
|
if (!dt || !dt.isValid) {
|
||||||
|
const fallback = getNow();
|
||||||
|
return fallback.set({
|
||||||
|
hour: BUSINESS_DAY_START_HOUR,
|
||||||
|
minute: 0,
|
||||||
|
second: 0,
|
||||||
|
millisecond: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameDayStart = dt.set({
|
||||||
|
hour: BUSINESS_DAY_START_HOUR,
|
||||||
|
minute: 0,
|
||||||
|
second: 0,
|
||||||
|
millisecond: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return dt.hour < BUSINESS_DAY_START_HOUR
|
||||||
|
? sameDayStart.minus({ days: 1 })
|
||||||
|
: sameDayStart;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDayEnd = (input = getNow()) => {
|
||||||
|
return getDayStart(input).plus({ days: 1 }).minus({ milliseconds: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWeekStart = (input = getNow()) => {
|
||||||
|
const dt = ensureDateTime(input);
|
||||||
|
if (!dt || !dt.isValid) {
|
||||||
|
return getDayStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfWeek = dt.set({ weekday: WEEK_START_DAY }).startOf('day');
|
||||||
|
const normalized = startOfWeek > dt ? startOfWeek.minus({ weeks: 1 }) : startOfWeek;
|
||||||
|
return normalized.set({
|
||||||
|
hour: BUSINESS_DAY_START_HOUR,
|
||||||
|
minute: 0,
|
||||||
|
second: 0,
|
||||||
|
millisecond: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRangeForTimeRange = (timeRange = 'today', now = getNow()) => {
|
||||||
|
const current = ensureDateTime(now);
|
||||||
|
if (!current || !current.isValid) {
|
||||||
|
throw new Error('Invalid reference time for range calculation');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (timeRange) {
|
||||||
|
case 'today': {
|
||||||
|
return {
|
||||||
|
start: getDayStart(current),
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
const target = current.minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
start: getDayStart(target),
|
||||||
|
end: getDayEnd(target)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'twoDaysAgo': {
|
||||||
|
const target = current.minus({ days: 2 });
|
||||||
|
return {
|
||||||
|
start: getDayStart(target),
|
||||||
|
end: getDayEnd(target)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisWeek': {
|
||||||
|
return {
|
||||||
|
start: getWeekStart(current),
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastWeek': {
|
||||||
|
const lastWeek = current.minus({ weeks: 1 });
|
||||||
|
const weekStart = getWeekStart(lastWeek);
|
||||||
|
const weekEnd = weekStart.plus({ days: 6 });
|
||||||
|
return {
|
||||||
|
start: weekStart,
|
||||||
|
end: getDayEnd(weekEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisMonth': {
|
||||||
|
const dayStart = getDayStart(current);
|
||||||
|
const monthStart = dayStart.startOf('month').set({ hour: BUSINESS_DAY_START_HOUR });
|
||||||
|
return {
|
||||||
|
start: monthStart,
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastMonth': {
|
||||||
|
const lastMonth = current.minus({ months: 1 });
|
||||||
|
const monthStart = lastMonth
|
||||||
|
.startOf('month')
|
||||||
|
.set({ hour: BUSINESS_DAY_START_HOUR, minute: 0, second: 0, millisecond: 0 });
|
||||||
|
const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
start: monthStart,
|
||||||
|
end: getDayEnd(monthEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last7days': {
|
||||||
|
const dayStart = getDayStart(current);
|
||||||
|
return {
|
||||||
|
start: dayStart.minus({ days: 6 }),
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last30days': {
|
||||||
|
const dayStart = getDayStart(current);
|
||||||
|
return {
|
||||||
|
start: dayStart.minus({ days: 29 }),
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
const dayStart = getDayStart(current);
|
||||||
|
return {
|
||||||
|
start: dayStart.minus({ days: 89 }),
|
||||||
|
end: getDayEnd(current)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous7days': {
|
||||||
|
const currentPeriodStart = getDayStart(current).minus({ days: 6 });
|
||||||
|
const previousEndDay = currentPeriodStart.minus({ days: 1 });
|
||||||
|
const previousStartDay = previousEndDay.minus({ days: 6 });
|
||||||
|
return {
|
||||||
|
start: getDayStart(previousStartDay),
|
||||||
|
end: getDayEnd(previousEndDay)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous30days': {
|
||||||
|
const currentPeriodStart = getDayStart(current).minus({ days: 29 });
|
||||||
|
const previousEndDay = currentPeriodStart.minus({ days: 1 });
|
||||||
|
const previousStartDay = previousEndDay.minus({ days: 29 });
|
||||||
|
return {
|
||||||
|
start: getDayStart(previousStartDay),
|
||||||
|
end: getDayEnd(previousEndDay)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'previous90days': {
|
||||||
|
const currentPeriodStart = getDayStart(current).minus({ days: 89 });
|
||||||
|
const previousEndDay = currentPeriodStart.minus({ days: 1 });
|
||||||
|
const previousStartDay = previousEndDay.minus({ days: 89 });
|
||||||
|
return {
|
||||||
|
start: getDayStart(previousStartDay),
|
||||||
|
end: getDayEnd(previousEndDay)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown time range: ${timeRange}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDatabaseSqlString = (dt) => {
|
||||||
|
const normalized = ensureDateTime(dt);
|
||||||
|
if (!normalized || !normalized.isValid) {
|
||||||
|
throw new Error('Invalid datetime provided for SQL conversion');
|
||||||
|
}
|
||||||
|
const dbTime = normalized.setZone(DB_TIMEZONE, { keepLocalTime: true });
|
||||||
|
return dbTime.toFormat(DB_DATETIME_FORMAT);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBusinessDate = (input) => {
|
||||||
|
const dt = ensureDateTime(input);
|
||||||
|
if (!dt || !dt.isValid) return '';
|
||||||
|
return dt.setZone(TIMEZONE).toFormat('LLL d, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeRangeLabel = (timeRange) => {
|
||||||
|
const labels = {
|
||||||
|
today: 'Today',
|
||||||
|
yesterday: 'Yesterday',
|
||||||
|
twoDaysAgo: 'Two Days Ago',
|
||||||
|
thisWeek: 'This Week',
|
||||||
|
lastWeek: 'Last Week',
|
||||||
|
thisMonth: 'This Month',
|
||||||
|
lastMonth: 'Last Month',
|
||||||
|
last7days: 'Last 7 Days',
|
||||||
|
last30days: 'Last 30 Days',
|
||||||
|
last90days: 'Last 90 Days',
|
||||||
|
previous7days: 'Previous 7 Days',
|
||||||
|
previous30days: 'Previous 30 Days',
|
||||||
|
previous90days: 'Previous 90 Days'
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[timeRange] || timeRange;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeRangeConditions = (timeRange, startDate, endDate) => {
|
||||||
|
if (timeRange === 'custom' && startDate && endDate) {
|
||||||
|
const start = ensureDateTime(startDate);
|
||||||
|
const end = ensureDateTime(endDate);
|
||||||
|
|
||||||
|
if (!start || !start.isValid || !end || !end.isValid) {
|
||||||
|
throw new Error('Invalid custom date range provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
whereClause: 'date_placed >= ? AND date_placed <= ?',
|
||||||
|
params: [toDatabaseSqlString(start), toDatabaseSqlString(end)],
|
||||||
|
dateRange: {
|
||||||
|
start: start.toUTC().toISO(),
|
||||||
|
end: end.toUTC().toISO(),
|
||||||
|
label: `${formatBusinessDate(start)} - ${formatBusinessDate(end)}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRange = timeRange || 'today';
|
||||||
|
const range = getRangeForTimeRange(normalizedRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
whereClause: 'date_placed >= ? AND date_placed <= ?',
|
||||||
|
params: [toDatabaseSqlString(range.start), toDatabaseSqlString(range.end)],
|
||||||
|
dateRange: {
|
||||||
|
start: range.start.toUTC().toISO(),
|
||||||
|
end: range.end.toUTC().toISO(),
|
||||||
|
label: getTimeRangeLabel(normalizedRange)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBusinessDayBounds = (timeRange) => {
|
||||||
|
const range = getRangeForTimeRange(timeRange);
|
||||||
|
return {
|
||||||
|
start: range.start.toJSDate(),
|
||||||
|
end: range.end.toJSDate()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBusinessDate = (mysqlDatetime) => {
|
||||||
|
if (!mysqlDatetime || mysqlDatetime === '0000-00-00 00:00:00') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dt = DateTime.fromSQL(mysqlDatetime, { zone: DB_TIMEZONE });
|
||||||
|
if (!dt.isValid) {
|
||||||
|
console.error('[timeUtils] Failed to parse MySQL datetime:', mysqlDatetime, dt.invalidExplanation);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dt.toUTC().toJSDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMySQLDate = (input) => {
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const dt = ensureDateTime(input, { zone: 'utc' });
|
||||||
|
if (!dt || !dt.isValid) return null;
|
||||||
|
|
||||||
|
return dt.setZone(DB_TIMEZONE).toFormat(DB_DATETIME_FORMAT);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getBusinessDayBounds,
|
||||||
|
getTimeRangeConditions,
|
||||||
|
formatBusinessDate,
|
||||||
|
getTimeRangeLabel,
|
||||||
|
parseBusinessDate,
|
||||||
|
formatMySQLDate,
|
||||||
|
// Expose helpers for tests or advanced consumers
|
||||||
|
_internal: {
|
||||||
|
getDayStart,
|
||||||
|
getDayEnd,
|
||||||
|
getWeekStart,
|
||||||
|
getRangeForTimeRange,
|
||||||
|
BUSINESS_DAY_START_HOUR
|
||||||
|
}
|
||||||
|
};
|
||||||
+2939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "dashboard-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Merged ESM dashboard server (klaviyo + meta + google-analytics + typeform). Phase 4 of CONSOLIDATION_PLAN.md.",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google-analytics/data": "^4.0.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"ioredis": "^5.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"pg": "^8.18.0",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-http": "^10.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Google Analytics router — ESM conversion of google-server/routes/analytics.routes.js.
|
||||||
|
// All routes are read-only — authenticated-only is sufficient; no extra permission.
|
||||||
|
// google_write is reserved for future write endpoints (per migration 005).
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { AnalyticsService } from '../../services/google/analytics.service.js';
|
||||||
|
|
||||||
|
export function createGoogleRouter({ redis }) {
|
||||||
|
const router = express.Router();
|
||||||
|
const service = new AnalyticsService(redis);
|
||||||
|
|
||||||
|
router.get('/metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { startDate = '7daysAgo' } = req.query;
|
||||||
|
const data = await service.getBasicMetrics(startDate);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metrics error:', { startDate: req.query.startDate, error: error.message });
|
||||||
|
res.status(500).json({ success: false, error: 'Failed to fetch metrics', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/realtime/basic', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await service.getRealTimeBasicData();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Realtime basic error:', { error: error.message });
|
||||||
|
res.status(500).json({ success: false, error: 'Failed to fetch realtime basic data', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/realtime/detailed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await service.getRealTimeDetailedData();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Realtime detailed error:', { error: error.message });
|
||||||
|
res.status(500).json({ success: false, error: 'Failed to fetch realtime detailed data', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/user-behavior', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange = '30' } = req.query;
|
||||||
|
const data = await service.getUserBehavior(timeRange);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('User behavior error:', { timeRange: req.query.timeRange, error: error.message });
|
||||||
|
res.status(500).json({ success: false, error: 'Failed to fetch user behavior data', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { CampaignsService } from '../../services/klaviyo/campaigns.service.js';
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
|
||||||
|
export function createCampaignsRouter(apiKey, apiRevision, redis) {
|
||||||
|
const router = express.Router();
|
||||||
|
const timeManager = new TimeManager();
|
||||||
|
const campaignsService = new CampaignsService(apiKey, apiRevision, redis);
|
||||||
|
|
||||||
|
// Get campaigns with optional filtering
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pageSize: parseInt(req.query.pageSize) || 50,
|
||||||
|
sort: req.query.sort || '-send_time',
|
||||||
|
status: req.query.status,
|
||||||
|
startDate: req.query.startDate,
|
||||||
|
endDate: req.query.endDate,
|
||||||
|
pageCursor: req.query.pageCursor
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Campaigns Route] Fetching campaigns with params:', params);
|
||||||
|
const data = await campaignsService.getCampaigns(params);
|
||||||
|
console.log('[Campaigns Route] Success:', {
|
||||||
|
count: data.data?.length || 0
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Campaigns Route] Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get campaigns by time range
|
||||||
|
router.get('/:timeRange', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange } = req.params;
|
||||||
|
const { status } = req.query;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (timeRange === 'custom') {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await campaignsService.getCampaigns({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await campaignsService.getCampaignsByTimeRange(
|
||||||
|
timeRange,
|
||||||
|
{ status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Campaigns Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { EventsService } from '../../services/klaviyo/events.service.js';
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
import { RedisService } from '../../services/klaviyo/redis.service.js';
|
||||||
|
import { requirePermission } from '../../../shared/auth/middleware.js';
|
||||||
|
|
||||||
|
// Import METRIC_IDS from events service
|
||||||
|
const METRIC_IDS = {
|
||||||
|
PLACED_ORDER: 'Y8cqcF',
|
||||||
|
SHIPPED_ORDER: 'VExpdL',
|
||||||
|
ACCOUNT_CREATED: 'TeeypV',
|
||||||
|
CANCELED_ORDER: 'YjVMNg',
|
||||||
|
NEW_BLOG_POST: 'YcxeDr',
|
||||||
|
PAYMENT_REFUNDED: 'R7XUYh'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createEventsRouter(apiKey, apiRevision, redis) {
|
||||||
|
const router = express.Router();
|
||||||
|
const timeManager = new TimeManager();
|
||||||
|
const eventsService = new EventsService(apiKey, apiRevision, redis);
|
||||||
|
const redisService = new RedisService(redis);
|
||||||
|
|
||||||
|
// Phase 6.2: clearCache is operational maintenance — requires klaviyo_admin.
|
||||||
|
// Mounted as path-level middleware so the existing POST handler below stays untouched.
|
||||||
|
router.use('/clearCache', requirePermission('klaviyo_admin'));
|
||||||
|
|
||||||
|
// Get events with optional filtering
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pageSize: parseInt(req.query.pageSize) || 50,
|
||||||
|
sort: req.query.sort || '-datetime',
|
||||||
|
metricId: req.query.metricId,
|
||||||
|
startDate: req.query.startDate,
|
||||||
|
endDate: req.query.endDate,
|
||||||
|
pageCursor: req.query.pageCursor,
|
||||||
|
fields: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse fields parameter if provided
|
||||||
|
if (req.query.fields) {
|
||||||
|
try {
|
||||||
|
params.fields = JSON.parse(req.query.fields);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Events Route] Invalid fields parameter:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Events Route] Fetching events with params:', params);
|
||||||
|
const data = await eventsService.getEvents(params);
|
||||||
|
console.log('[Events Route] Success:', {
|
||||||
|
count: data.data?.length || 0,
|
||||||
|
included: data.included?.length || 0
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Events Route] Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get events by time range
|
||||||
|
router.get('/by-time/:timeRange', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange } = req.params;
|
||||||
|
const { metricId, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (timeRange === 'custom') {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return res.status(400).json({ error: 'Custom range requires startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid date range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await eventsService.getEvents({
|
||||||
|
metricId,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await eventsService.getEventsByTimeRange(
|
||||||
|
timeRange,
|
||||||
|
{ metricId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get comprehensive statistics for a time period
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
console.log('[Events Route] Stats request:', {
|
||||||
|
timeRange,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Events Route] Calculating period stats with params:', params);
|
||||||
|
const stats = await eventsService.calculatePeriodStats(params);
|
||||||
|
console.log('[Events Route] Stats response:', {
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO()
|
||||||
|
},
|
||||||
|
shippedCount: stats?.shipping?.shippedCount,
|
||||||
|
totalOrders: stats?.orderCount
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new route for smart revenue projection
|
||||||
|
router.get('/projection', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
console.log('[Events Route] Projection request:', {
|
||||||
|
timeRange,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first with a short TTL
|
||||||
|
const cacheKey = redisService._getCacheKey('projection', params);
|
||||||
|
const cachedData = await redisService.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[Events Route] Cache hit for projection');
|
||||||
|
return res.json(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Events Route] Calculating smart projection with params:', params);
|
||||||
|
const projection = await eventsService.calculateSmartProjection(params);
|
||||||
|
|
||||||
|
// Cache the results with a short TTL (5 minutes)
|
||||||
|
await redisService.set(cacheKey, projection, 300);
|
||||||
|
|
||||||
|
res.json(projection);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error calculating projection:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new route for detailed stats
|
||||||
|
router.get('/stats/details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metric, daily = false } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metric,
|
||||||
|
daily: daily === 'true' || daily === true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = redisService._getCacheKey('stats:details', params);
|
||||||
|
const cachedData = await redisService.get(cacheKey);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[Events Route] Cache hit for detailed stats');
|
||||||
|
return res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats: cachedData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await eventsService.calculateDetailedStats(params);
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
const ttl = redisService._getTTL(timeRange);
|
||||||
|
await redisService.set(cacheKey, stats, ttl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get product statistics for a time period
|
||||||
|
router.get('/products', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = redisService._getCacheKey('events', params);
|
||||||
|
const cachedData = await redisService.getEventData('products', params);
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[Events Route] Cache hit for products');
|
||||||
|
return res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
products: cachedData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await eventsService.calculatePeriodStats(params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get event feed (multiple event types sorted by time)
|
||||||
|
router.get('/feed', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metricIds } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metricIds: metricIds ? JSON.parse(metricIds) : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await eventsService.getMultiMetricEvents(params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
...result
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get aggregated events data
|
||||||
|
router.get('/aggregate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, interval = 'day', metricId, property } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else if (timeRange) {
|
||||||
|
range = timeManager.getDateRange(timeRange);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: 'Must provide either timeRange or startDate and endDate' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({ error: 'Invalid time range' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
metricId,
|
||||||
|
interval,
|
||||||
|
property
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await eventsService.getEvents(params);
|
||||||
|
const groupedData = timeManager.groupEventsByInterval(result.data, interval, property);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
timeRange: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
},
|
||||||
|
data: groupedData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Events Route] Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get date range for a given time period
|
||||||
|
router.get("/dateRange", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
let range;
|
||||||
|
if (startDate && endDate) {
|
||||||
|
range = timeManager.getCustomRange(startDate, endDate);
|
||||||
|
} else {
|
||||||
|
range = timeManager.getDateRange(timeRange || 'today');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid time range parameters"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO(),
|
||||||
|
displayStart: timeManager.formatForDisplay(range.start),
|
||||||
|
displayEnd: timeManager.formatForDisplay(range.end)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting date range:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Failed to get date range"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear cache for a specific time range
|
||||||
|
router.post("/clearCache", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate } = req.body;
|
||||||
|
await redisService.clearCache({ timeRange, startDate, endDate });
|
||||||
|
res.json({ message: "Cache cleared successfully" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
res.status(500).json({ error: "Failed to clear cache" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new batch metrics endpoint
|
||||||
|
router.get('/batch', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange, startDate, endDate, metrics } = req.query;
|
||||||
|
|
||||||
|
// Parse metrics array from query
|
||||||
|
const metricsList = metrics ? JSON.parse(metrics) : [];
|
||||||
|
|
||||||
|
const params = timeRange === 'custom'
|
||||||
|
? { startDate, endDate, metrics: metricsList }
|
||||||
|
: { timeRange, metrics: metricsList };
|
||||||
|
|
||||||
|
const results = await eventsService.getBatchMetrics(params);
|
||||||
|
|
||||||
|
res.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Events Route] Error in batch request:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Klaviyo router factory. Phase 4 merge: takes the injected redis client and
|
||||||
|
// the env-resolved API key/revision, returns the mounted /api/klaviyo router
|
||||||
|
// (matches Caddy proxy path; no rewrite needed).
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { requirePermission } from '../../../shared/auth/middleware.js';
|
||||||
|
|
||||||
|
import { createEventsRouter } from './events.routes.js';
|
||||||
|
import { createMetricsRouter } from './metrics.routes.js';
|
||||||
|
import { createCampaignsRouter } from './campaigns.routes.js';
|
||||||
|
import { createReportingRouter } from './reporting.routes.js';
|
||||||
|
|
||||||
|
export function createKlaviyoRouter({ redis }) {
|
||||||
|
const apiKey = process.env.KLAVIYO_API_KEY;
|
||||||
|
const apiRevision = process.env.KLAVIYO_API_REVISION || '2024-02-15';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
// Loud at startup; the routes themselves will 500 on every call without it.
|
||||||
|
console.warn('[klaviyo] KLAVIYO_API_KEY not set — Klaviyo endpoints will fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Phase 4 carryover from klaviyo-server: throttle the heavy /reporting/campaign-values-reports
|
||||||
|
// endpoint. authenticate() already runs upstream so we don't add a per-user limiter here.
|
||||||
|
const reportingLimiter = rateLimit({
|
||||||
|
windowMs: 10 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
message: 'Too many requests to reporting endpoint, please try again later',
|
||||||
|
keyGenerator: (req) => `${req.ip}-klaviyo-reporting`,
|
||||||
|
skip: (req) => !req.path.includes('campaign-values-reports'),
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
router.use('/reporting', reportingLimiter);
|
||||||
|
|
||||||
|
router.use('/events', createEventsRouter(apiKey, apiRevision, redis));
|
||||||
|
router.use('/metrics', createMetricsRouter(apiKey, apiRevision));
|
||||||
|
router.use('/campaigns', createCampaignsRouter(apiKey, apiRevision, redis));
|
||||||
|
router.use('/reporting', createReportingRouter(apiKey, apiRevision, redis));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-exported so the dashboard server / future tests can attach the
|
||||||
|
// klaviyo_admin gate without reaching into the events router file.
|
||||||
|
export { requirePermission };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { MetricsService } from '../../services/klaviyo/metrics.service.js';
|
||||||
|
|
||||||
|
export function createMetricsRouter(apiKey, apiRevision) {
|
||||||
|
const router = express.Router();
|
||||||
|
const metricsService = new MetricsService(apiKey, apiRevision);
|
||||||
|
|
||||||
|
// Get all metrics
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[Metrics Route] Fetching metrics');
|
||||||
|
const data = await metricsService.getMetrics();
|
||||||
|
console.log('[Metrics Route] Success:', {
|
||||||
|
count: data.data?.length || 0
|
||||||
|
});
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Metrics Route] Error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
message: error.message,
|
||||||
|
details: error.response?.data || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { ReportingService } from '../../services/klaviyo/reporting.service.js';
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
|
||||||
|
export function createReportingRouter(apiKey, apiRevision, redis) {
|
||||||
|
const router = express.Router();
|
||||||
|
const reportingService = new ReportingService(apiKey, apiRevision, redis);
|
||||||
|
const timeManager = new TimeManager();
|
||||||
|
|
||||||
|
// Get campaign reports by time range
|
||||||
|
router.get('/campaigns/:timeRange', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { timeRange } = req.params;
|
||||||
|
const { channel } = req.query;
|
||||||
|
|
||||||
|
const reports = await reportingService.getCampaignReports({
|
||||||
|
timeRange,
|
||||||
|
channel
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(reports);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ReportingRoutes] Error fetching campaign reports:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Meta router factory — ESM conversion of meta-server/routes/campaigns.routes.js.
|
||||||
|
// Phase 6.2: mutations (PATCH /campaigns/:id/budget, POST /campaigns/:id/:action)
|
||||||
|
// require the `meta_write` permission. Reads (GET) stay authenticated-only.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { requirePermission } from '../../../shared/auth/middleware.js';
|
||||||
|
import {
|
||||||
|
fetchCampaigns,
|
||||||
|
fetchAccountInsights,
|
||||||
|
updateCampaignBudget,
|
||||||
|
updateCampaignStatus,
|
||||||
|
} from '../../services/meta/meta.service.js';
|
||||||
|
|
||||||
|
export function createMetaRouter() {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Reads — authenticated-only
|
||||||
|
router.get('/campaigns', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { since, until } = req.query;
|
||||||
|
if (!since || !until) {
|
||||||
|
return res.status(400).json({ error: 'Date range is required (since, until)' });
|
||||||
|
}
|
||||||
|
const campaigns = await fetchCampaigns(since, until);
|
||||||
|
res.json(campaigns);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Campaign fetch error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch campaigns',
|
||||||
|
details: error.response?.data?.error?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/account-insights', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { since, until } = req.query;
|
||||||
|
if (!since || !until) {
|
||||||
|
return res.status(400).json({ error: 'Date range is required (since, until)' });
|
||||||
|
}
|
||||||
|
const insights = await fetchAccountInsights(since, until);
|
||||||
|
res.json(insights);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Account insights fetch error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch account insights',
|
||||||
|
details: error.response?.data?.error?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Writes — meta_write
|
||||||
|
router.patch('/campaigns/:campaignId/budget', requirePermission('meta_write'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { campaignId } = req.params;
|
||||||
|
const { budget } = req.body;
|
||||||
|
if (!budget) {
|
||||||
|
return res.status(400).json({ error: 'Budget is required' });
|
||||||
|
}
|
||||||
|
const result = await updateCampaignBudget(campaignId, budget);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Budget update error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to update campaign budget',
|
||||||
|
details: error.response?.data?.error?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/campaigns/:campaignId/:action', requirePermission('meta_write'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { campaignId, action } = req.params;
|
||||||
|
if (!['pause', 'unpause'].includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid action. Use "pause" or "unpause"' });
|
||||||
|
}
|
||||||
|
const result = await updateCampaignStatus(campaignId, action);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status update error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to update campaign status',
|
||||||
|
details: error.response?.data?.error?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Typeform router — ESM conversion of typeform-server/routes/typeform.routes.js.
|
||||||
|
// All routes read-only — authenticated-only is sufficient; typeform_write reserved
|
||||||
|
// for future write endpoints (per migration 005).
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { TypeformService } from '../../services/typeform/typeform.service.js';
|
||||||
|
|
||||||
|
export function createTypeformRouter({ redis }) {
|
||||||
|
const router = express.Router();
|
||||||
|
const typeform = new TypeformService(redis);
|
||||||
|
|
||||||
|
router.get('/forms/:formId/responses', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { formId } = req.params;
|
||||||
|
const filters = req.query;
|
||||||
|
if (!formId) {
|
||||||
|
return res.status(400).json({ error: 'Missing form ID', details: 'The form ID parameter is required' });
|
||||||
|
}
|
||||||
|
const data = await typeform.getFormResponsesWithFilters(formId, filters);
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({ error: 'No data found', details: `No responses found for form ${formId}` });
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form responses error:', {
|
||||||
|
formId: req.params.formId,
|
||||||
|
filters: req.query,
|
||||||
|
error: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
});
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
return res.status(401).json({ error: 'Authentication failed', details: 'Invalid Typeform API credentials' });
|
||||||
|
}
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return res.status(404).json({ error: 'Not found', details: `Form '${req.params.formId}' 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 form responses',
|
||||||
|
details: error.response?.data?.message || error.message,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/forms/:formId/insights', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { formId } = req.params;
|
||||||
|
if (!formId) {
|
||||||
|
return res.status(400).json({ error: 'Missing form ID', details: 'The form ID parameter is required' });
|
||||||
|
}
|
||||||
|
const data = await typeform.getFormInsights(formId);
|
||||||
|
if (!data) {
|
||||||
|
return res.status(404).json({ error: 'No data found', details: `No insights found for form ${formId}` });
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Form insights error:', {
|
||||||
|
formId: req.params.formId,
|
||||||
|
error: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
});
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
return res.status(401).json({ error: 'Authentication failed', details: 'Invalid Typeform API credentials' });
|
||||||
|
}
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return res.status(404).json({ error: 'Not found', details: `Form '${req.params.formId}' not found` });
|
||||||
|
}
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch form insights',
|
||||||
|
details: error.response?.data?.message || error.message,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Stores individual product links found in Klaviyo campaign emails
|
||||||
|
CREATE TABLE IF NOT EXISTS klaviyo_campaign_products (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
campaign_id TEXT NOT NULL,
|
||||||
|
campaign_name TEXT,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
pid BIGINT NOT NULL,
|
||||||
|
product_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(campaign_id, pid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kcp_campaign_id ON klaviyo_campaign_products(campaign_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kcp_pid ON klaviyo_campaign_products(pid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kcp_sent_at ON klaviyo_campaign_products(sent_at);
|
||||||
|
|
||||||
|
-- Stores non-product shop links (categories, filters, etc.) found in campaigns
|
||||||
|
CREATE TABLE IF NOT EXISTS klaviyo_campaign_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
campaign_id TEXT NOT NULL,
|
||||||
|
campaign_name TEXT,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
link_url TEXT NOT NULL,
|
||||||
|
link_type TEXT, -- 'category', 'brand', 'filter', 'clearance', 'deals', 'other'
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(campaign_id, link_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kcl_campaign_id ON klaviyo_campaign_links(campaign_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kcl_sent_at ON klaviyo_campaign_links(sent_at);
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Extract products featured in Klaviyo campaign emails and store in DB.
|
||||||
|
*
|
||||||
|
* - Fetches recent sent campaigns from Klaviyo API
|
||||||
|
* - Gets template HTML for each campaign message
|
||||||
|
* - Parses out product links (/shop/{id}) and other shop links
|
||||||
|
* - Inserts into klaviyo_campaign_products and klaviyo_campaign_links tables
|
||||||
|
*
|
||||||
|
* Usage: node scripts/poc-campaign-products.js [limit] [offset]
|
||||||
|
* limit: number of sent campaigns to process (default: 10)
|
||||||
|
* offset: number of sent campaigns to skip before processing (default: 0)
|
||||||
|
*
|
||||||
|
* Requires DB_* env vars (from inventory-server .env) and KLAVIYO_API_KEY.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Load klaviyo .env for API key
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||||
|
// Also load the main inventory-server .env for DB credentials
|
||||||
|
const mainEnvPath = '/var/www/inventory/.env';
|
||||||
|
if (fs.existsSync(mainEnvPath)) {
|
||||||
|
dotenv.config({ path: mainEnvPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_KEY = process.env.KLAVIYO_API_KEY;
|
||||||
|
const REVISION = process.env.KLAVIYO_API_REVISION || '2026-01-15';
|
||||||
|
const BASE_URL = 'https://a.klaviyo.com/api';
|
||||||
|
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error('KLAVIYO_API_KEY not set in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Klaviyo API helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Klaviyo-API-Key ${API_KEY}`,
|
||||||
|
'revision': REVISION,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function klaviyoGet(endpoint, params = {}) {
|
||||||
|
const url = new URL(`${BASE_URL}${endpoint}`);
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
url.searchParams.append(k, v);
|
||||||
|
}
|
||||||
|
return klaviyoFetch(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function klaviyoFetch(url) {
|
||||||
|
const res = await fetch(url, { headers });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`Klaviyo ${res.status} on ${url}: ${body}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecentCampaigns(limit, offset = 0) {
|
||||||
|
const campaigns = [];
|
||||||
|
const messageMap = {};
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
let data = await klaviyoGet('/campaigns', {
|
||||||
|
'filter': 'equals(messages.channel,"email")',
|
||||||
|
'sort': '-scheduled_at',
|
||||||
|
'include': 'campaign-messages',
|
||||||
|
});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
for (const c of (data.data || [])) {
|
||||||
|
if (c.attributes?.status === 'Sent') {
|
||||||
|
if (skipped < offset) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
campaigns.push(c);
|
||||||
|
if (campaigns.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inc of (data.included || [])) {
|
||||||
|
if (inc.type === 'campaign-message') {
|
||||||
|
messageMap[inc.id] = inc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = data.links?.next;
|
||||||
|
if (campaigns.length >= limit || !nextUrl) break;
|
||||||
|
|
||||||
|
const progress = skipped < offset
|
||||||
|
? `Skipped ${skipped}/${offset}...`
|
||||||
|
: `Fetched ${campaigns.length}/${limit} sent campaigns, loading next page...`;
|
||||||
|
console.log(` ${progress}`);
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
data = await klaviyoFetch(nextUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { campaigns: campaigns.slice(0, limit), messageMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTemplateHtml(messageId) {
|
||||||
|
const data = await klaviyoGet(`/campaign-messages/${messageId}/template`, {
|
||||||
|
'fields[template]': 'html,name',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
templateId: data.data?.id,
|
||||||
|
templateName: data.data?.attributes?.name,
|
||||||
|
html: data.data?.attributes?.html || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML parsing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseProductsFromHtml(html) {
|
||||||
|
const seen = new Set();
|
||||||
|
const products = [];
|
||||||
|
|
||||||
|
const linkRegex = /href="([^"]*acherryontop\.com\/shop\/(\d+))[^"]*"/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = linkRegex.exec(html)) !== null) {
|
||||||
|
const productId = match[2];
|
||||||
|
if (!seen.has(productId)) {
|
||||||
|
seen.add(productId);
|
||||||
|
products.push({
|
||||||
|
siteProductId: productId,
|
||||||
|
url: match[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLinks = [];
|
||||||
|
const catRegex = /href="([^"]*acherryontop\.com\/shop\/[^"]+)"/gi;
|
||||||
|
while ((match = catRegex.exec(html)) !== null) {
|
||||||
|
const url = match[1];
|
||||||
|
if (/\/shop\/\d+$/.test(url)) continue;
|
||||||
|
if (!categoryLinks.includes(url)) categoryLinks.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { products, categoryLinks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyLink(url) {
|
||||||
|
if (/\/shop\/(new|pre-order|backinstock)/.test(url)) return 'filter';
|
||||||
|
if (/\/shop\/company\//.test(url)) return 'brand';
|
||||||
|
if (/\/shop\/clearance/.test(url)) return 'clearance';
|
||||||
|
if (/\/shop\/daily_deals/.test(url)) return 'deals';
|
||||||
|
if (/\/shop\/category\//.test(url)) return 'category';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Database ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createPool() {
|
||||||
|
return new pg.Pool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertProducts(pool, campaignId, campaignName, sentAt, products) {
|
||||||
|
if (products.length === 0) return 0;
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const p of products) {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO klaviyo_campaign_products
|
||||||
|
(campaign_id, campaign_name, sent_at, pid, product_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (campaign_id, pid) DO NOTHING`,
|
||||||
|
[campaignId, campaignName, sentAt, parseInt(p.siteProductId), p.url]
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Error inserting product ${p.siteProductId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertLinks(pool, campaignId, campaignName, sentAt, links) {
|
||||||
|
if (links.length === 0) return 0;
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const url of links) {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO klaviyo_campaign_links
|
||||||
|
(campaign_id, campaign_name, sent_at, link_url, link_type)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (campaign_id, link_url) DO NOTHING`,
|
||||||
|
[campaignId, campaignName, sentAt, url, classifyLink(url)]
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Error inserting link: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const limit = parseInt(process.argv[2]) || 10;
|
||||||
|
const offset = parseInt(process.argv[3]) || 0;
|
||||||
|
const pool = createPool();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch campaigns
|
||||||
|
console.log(`Fetching up to ${limit} recent campaigns (offset: ${offset})...\n`);
|
||||||
|
const { campaigns, messageMap } = await getRecentCampaigns(limit, offset);
|
||||||
|
console.log(`Found ${campaigns.length} sent campaigns.\n`);
|
||||||
|
|
||||||
|
let totalProducts = 0;
|
||||||
|
let totalLinks = 0;
|
||||||
|
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
const name = campaign.attributes?.name || 'Unnamed';
|
||||||
|
const sentAt = campaign.attributes?.send_time;
|
||||||
|
|
||||||
|
console.log(`━━━ ${name} (${sentAt?.slice(0, 10) || 'no date'}) ━━━`);
|
||||||
|
|
||||||
|
const msgIds = (campaign.relationships?.['campaign-messages']?.data || [])
|
||||||
|
.map(r => r.id);
|
||||||
|
|
||||||
|
if (msgIds.length === 0) {
|
||||||
|
console.log(' No messages.\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msgId of msgIds) {
|
||||||
|
const msg = messageMap[msgId];
|
||||||
|
const subject = msg?.attributes?.definition?.content?.subject;
|
||||||
|
if (subject) console.log(` Subject: ${subject}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await getTemplateHtml(msgId);
|
||||||
|
const { products, categoryLinks } = parseProductsFromHtml(template.html);
|
||||||
|
|
||||||
|
const pInserted = await insertProducts(pool, campaign.id, name, sentAt, products);
|
||||||
|
const lInserted = await insertLinks(pool, campaign.id, name, sentAt, categoryLinks);
|
||||||
|
|
||||||
|
console.log(` ${products.length} products (${pInserted} new), ${categoryLinks.length} links (${lInserted} new)`);
|
||||||
|
totalProducts += pInserted;
|
||||||
|
totalLinks += lInserted;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Done. Inserted ${totalProducts} product rows, ${totalLinks} link rows.`);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// dashboard-server — Phase 4 of CONSOLIDATION_PLAN.md.
|
||||||
|
// Merges the four per-vendor PM2 apps (klaviyo, meta, google-analytics, typeform)
|
||||||
|
// into a single ESM service on DASHBOARD_PORT (default 3015).
|
||||||
|
//
|
||||||
|
// Mount points (matches Caddy proxy paths):
|
||||||
|
// /api/klaviyo/* → routes/klaviyo (was klaviyo-server :3004)
|
||||||
|
// /api/meta/* → routes/meta (was meta-server :3005)
|
||||||
|
// /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite)
|
||||||
|
// /api/typeform/* → routes/typeform (was typeform-server :3008)
|
||||||
|
//
|
||||||
|
// Shared infrastructure (Phase 2 + Phase 6):
|
||||||
|
// - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense)
|
||||||
|
// - shared/cors/policy.js explicit allowed-origins list (Phase 6.6)
|
||||||
|
// - shared/logging/request-log.js pino-http, Authorization/Cookie redacted (Phase 6.5/6.9)
|
||||||
|
// - shared/errors/handler.js consistent error envelope, no leak in prod
|
||||||
|
// - shared/db/pg.js / shared/db/redis.js one Pool + one ioredis client for all vendors
|
||||||
|
//
|
||||||
|
// Per-route permission gates (Phase 6.2):
|
||||||
|
// - meta_write PATCH/POST mutations to Meta campaigns
|
||||||
|
// - klaviyo_admin POST /api/klaviyo/events/clearCache (operational maintenance)
|
||||||
|
// Read-only Google + Typeform endpoints stay authenticated-only.
|
||||||
|
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { authenticate } from '../shared/auth/middleware.js';
|
||||||
|
import { corsOptions } from '../shared/cors/policy.js';
|
||||||
|
import { createPool } from '../shared/db/pg.js';
|
||||||
|
import { createRedis } from '../shared/db/redis.js';
|
||||||
|
import { errorHandler } from '../shared/errors/handler.js';
|
||||||
|
import { logger } from '../shared/logging/logger.js';
|
||||||
|
import { requestLog } from '../shared/logging/request-log.js';
|
||||||
|
|
||||||
|
import { createKlaviyoRouter } from './routes/klaviyo/index.js';
|
||||||
|
import { createMetaRouter } from './routes/meta/index.js';
|
||||||
|
import { createGoogleRouter } from './routes/google/index.js';
|
||||||
|
import { createTypeformRouter } from './routes/typeform/index.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Layer envs: shared inventory .env wins on collisions (security-critical vars come
|
||||||
|
// from one place); vendor-specific keys come from the per-service .env.
|
||||||
|
//
|
||||||
|
// dotenv defaults to override:false so the first file loaded wins. Order matters.
|
||||||
|
const sharedEnvPath = '/var/www/inventory/.env';
|
||||||
|
const dashboardEnvPath = path.resolve(__dirname, '.env');
|
||||||
|
|
||||||
|
if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath });
|
||||||
|
if (fs.existsSync(dashboardEnvPath)) loadEnv({ path: dashboardEnvPath });
|
||||||
|
|
||||||
|
// Phase 6.4 — refuse to start without JWT_SECRET. Without it authenticate() falls
|
||||||
|
// back to res.status(401) on every request and the service is useless anyway.
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
logger.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = Number(process.env.DASHBOARD_PORT) || 3015;
|
||||||
|
|
||||||
|
// Single Postgres pool — used by authenticate() to load user permissions.
|
||||||
|
// All four vendors share this pool (auth lookups are the only DB hits at runtime).
|
||||||
|
const pool = createPool('DB');
|
||||||
|
|
||||||
|
// Single ioredis client shared across all vendors. lazyConnect:true means the
|
||||||
|
// first .get/.set triggers the actual connect — keeps startup non-blocking even
|
||||||
|
// if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults.
|
||||||
|
const redis = createRedis();
|
||||||
|
|
||||||
|
app.use(requestLog());
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// Phase 6.1/6.2: every /api request requires a valid JWT. authenticate() also
|
||||||
|
// loads user permissions, which the per-route requirePermission() checks rely on.
|
||||||
|
app.use('/api', authenticate({ pool, secret: process.env.JWT_SECRET }));
|
||||||
|
|
||||||
|
app.use('/api/klaviyo', createKlaviyoRouter({ redis }));
|
||||||
|
app.use('/api/meta', createMetaRouter());
|
||||||
|
// Note: frontend calls /api/dashboard-analytics (Caddy used to rewrite it to
|
||||||
|
// /api/analytics for the standalone google-server). Mount at the public path so
|
||||||
|
// Caddy can drop the rewrite — see Caddyfile.proposed.
|
||||||
|
app.use('/api/dashboard-analytics', createGoogleRouter({ redis }));
|
||||||
|
app.use('/api/typeform', createTypeformRouter({ redis }));
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'dashboard-server',
|
||||||
|
redis: redis.status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Connect Redis up front so the first request doesn't pay the connect cost.
|
||||||
|
// Failures here are non-fatal — vendors degrade to cache-miss → upstream fetch.
|
||||||
|
redis.connect().catch((err) => {
|
||||||
|
logger.error({ err: { message: err.message, code: err.code } }, 'redis lazy-connect failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
logger.info({ port: PORT, mode: process.env.NODE_ENV || 'development' }, 'dashboard-server listening');
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = async (signal) => {
|
||||||
|
logger.info({ signal }, 'dashboard-server shutting down');
|
||||||
|
server.close();
|
||||||
|
try { await redis.quit(); } catch { /* ignore */ }
|
||||||
|
try { await pool.end(); } catch { /* ignore */ }
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
logger.error({ err: { message: err.message, stack: err.stack } }, 'uncaughtException');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error({ reason }, 'unhandledRejection');
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Google Analytics (GA4) service — ESM conversion of google-server/services/analytics.service.js.
|
||||||
|
// Phase 4: accepts injected ioredis client (was self-constructing node-redis v4 before).
|
||||||
|
// node-redis v4 set syntax `{ EX: 300 }` is translated to ioredis `setex(key, 300, val)`.
|
||||||
|
|
||||||
|
import { BetaAnalyticsDataClient } from '@google-analytics/data';
|
||||||
|
|
||||||
|
const CACHE_DURATIONS = {
|
||||||
|
REALTIME_BASIC: 60,
|
||||||
|
REALTIME_DETAILED: 300,
|
||||||
|
BASIC_METRICS: 3600,
|
||||||
|
USER_BEHAVIOR: 3600,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AnalyticsService {
|
||||||
|
constructor(redis) {
|
||||||
|
if (!redis) {
|
||||||
|
throw new Error('AnalyticsService requires an ioredis client (Phase 4: injected)');
|
||||||
|
}
|
||||||
|
this.redis = redis;
|
||||||
|
|
||||||
|
const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON;
|
||||||
|
this.analyticsClient = new BetaAnalyticsDataClient({
|
||||||
|
credentials: typeof credentials === 'string' ? JSON.parse(credentials) : credentials,
|
||||||
|
});
|
||||||
|
this.propertyId = process.env.GA_PROPERTY_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _redisReady() {
|
||||||
|
return this.redis.status === 'ready' || this.redis.status === 'connect';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cacheGet(key) {
|
||||||
|
if (!this._redisReady) return null;
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(key);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AnalyticsService] cache get failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cacheSet(key, value, ttlSec) {
|
||||||
|
if (!this._redisReady) return;
|
||||||
|
try {
|
||||||
|
await this.redis.setex(key, ttlSec, JSON.stringify(value));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AnalyticsService] cache set failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBasicMetrics(startDate = '7daysAgo') {
|
||||||
|
const cacheKey = `analytics:basic_metrics:${startDate}`;
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [response] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'date' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'activeUsers' },
|
||||||
|
{ name: 'newUsers' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'conversions' },
|
||||||
|
],
|
||||||
|
returnPropertyQuota: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.BASIC_METRICS);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealTimeBasicData() {
|
||||||
|
const cacheKey = 'analytics:realtime:basic';
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [userResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
returnPropertyQuota: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [fiveMinResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
minuteRanges: [{ startMinutesAgo: 5, endMinutesAgo: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [timeSeriesResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'minutesAgo' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
userResponse,
|
||||||
|
fiveMinResponse,
|
||||||
|
timeSeriesResponse,
|
||||||
|
quotaInfo: {
|
||||||
|
projectHourly: userResponse.propertyQuota.tokensPerProjectPerHour,
|
||||||
|
daily: userResponse.propertyQuota.tokensPerDay,
|
||||||
|
serverErrors: userResponse.propertyQuota.serverErrorsPerProjectPerHour,
|
||||||
|
thresholdedRequests: userResponse.propertyQuota.potentiallyThresholdedRequestsPerHour,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.REALTIME_BASIC);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRealTimeDetailedData() {
|
||||||
|
const cacheKey = 'analytics:realtime:detailed';
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [pageResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'unifiedScreenName' }],
|
||||||
|
metrics: [{ name: 'screenPageViews' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [eventResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'eventName' }],
|
||||||
|
metrics: [{ name: 'eventCount' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'eventCount' }, desc: true }],
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deviceResponse] = await this.analyticsClient.runRealtimeReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [{ name: 'activeUsers' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'activeUsers' }, desc: true }],
|
||||||
|
limit: 10,
|
||||||
|
returnPropertyQuota: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
pageResponse,
|
||||||
|
eventResponse,
|
||||||
|
sourceResponse: deviceResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.REALTIME_DETAILED);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBehavior(timeRange = '30') {
|
||||||
|
const cacheKey = `analytics:user_behavior:${timeRange}`;
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const [pageResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'pagePath' }],
|
||||||
|
metrics: [
|
||||||
|
{ name: 'screenPageViews' },
|
||||||
|
{ name: 'averageSessionDuration' },
|
||||||
|
{ name: 'bounceRate' },
|
||||||
|
{ name: 'sessions' },
|
||||||
|
],
|
||||||
|
orderBy: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deviceResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'deviceCategory' }],
|
||||||
|
metrics: [{ name: 'screenPageViews' }, { name: 'sessions' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sourceResponse] = await this.analyticsClient.runReport({
|
||||||
|
property: `properties/${this.propertyId}`,
|
||||||
|
dateRanges: [{ startDate: `${timeRange}daysAgo`, endDate: 'today' }],
|
||||||
|
dimensions: [{ name: 'sessionSource' }],
|
||||||
|
metrics: [{ name: 'sessions' }, { name: 'conversions' }],
|
||||||
|
orderBy: [{ metric: { metricName: 'sessions' }, desc: true }],
|
||||||
|
limit: 25,
|
||||||
|
returnPropertyQuota: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = { pageResponse, deviceResponse, sourceResponse };
|
||||||
|
await this._cacheSet(cacheKey, response, CACHE_DURATIONS.USER_BEHAVIOR);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
import { RedisService } from './redis.service.js';
|
||||||
|
|
||||||
|
export class CampaignsService {
|
||||||
|
constructor(apiKey, apiRevision, redis) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.apiRevision = apiRevision;
|
||||||
|
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||||
|
this.timeManager = new TimeManager();
|
||||||
|
this.redisService = new RedisService(redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCampaigns(params = {}) {
|
||||||
|
try {
|
||||||
|
// Add request debouncing
|
||||||
|
const requestKey = JSON.stringify(params);
|
||||||
|
if (this._pendingRequests && this._pendingRequests[requestKey]) {
|
||||||
|
return this._pendingRequests[requestKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||||
|
let cachedData = null;
|
||||||
|
try {
|
||||||
|
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||||
|
if (cachedData) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pendingRequests = this._pendingRequests || {};
|
||||||
|
this._pendingRequests[requestKey] = (async () => {
|
||||||
|
let allCampaigns = [];
|
||||||
|
let nextCursor = params.pageCursor;
|
||||||
|
let pageCount = 0;
|
||||||
|
|
||||||
|
const filter = params.filter || this._buildFilter(params);
|
||||||
|
|
||||||
|
do {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (filter) {
|
||||||
|
queryParams.append('filter', filter);
|
||||||
|
}
|
||||||
|
queryParams.append('sort', params.sort || '-send_time');
|
||||||
|
|
||||||
|
if (nextCursor) {
|
||||||
|
queryParams.append('page[cursor]', nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/campaigns?${queryParams.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||||
|
'revision': this.apiRevision
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('[CampaignsService] API Error:', errorData);
|
||||||
|
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
allCampaigns = allCampaigns.concat(responseData.data || []);
|
||||||
|
pageCount++;
|
||||||
|
|
||||||
|
nextCursor = responseData.links?.next ?
|
||||||
|
new URL(responseData.links.next).searchParams.get('page[cursor]') : null;
|
||||||
|
|
||||||
|
if (nextCursor) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.error('[CampaignsService] Fetch error:', fetchError);
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
|
||||||
|
} while (nextCursor);
|
||||||
|
|
||||||
|
const transformedCampaigns = this._transformCampaigns(allCampaigns);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
data: transformedCampaigns,
|
||||||
|
meta: {
|
||||||
|
total_count: transformedCampaigns.length,
|
||||||
|
page_count: pageCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ttl = this.redisService._getTTL(params.timeRange);
|
||||||
|
await this.redisService.set(`${cacheKey}:raw`, result, ttl);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[CampaignsService] Cache set error:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this._pendingRequests[requestKey];
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return await this._pendingRequests[requestKey];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CampaignsService] Error fetching campaigns:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildFilter(params) {
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
if (params.startDate && params.endDate) {
|
||||||
|
const startUtc = this.timeManager.formatForAPI(params.startDate);
|
||||||
|
const endUtc = this.timeManager.formatForAPI(params.endDate);
|
||||||
|
|
||||||
|
filters.push(`greater-or-equal(send_time,${startUtc})`);
|
||||||
|
filters.push(`less-than(send_time,${endUtc})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.status) {
|
||||||
|
filters.push(`equals(status,"${params.status}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.customFilters) {
|
||||||
|
filters.push(...params.customFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCampaignsByTimeRange(timeRange, options = {}) {
|
||||||
|
const range = this.timeManager.getDateRange(timeRange);
|
||||||
|
if (!range) {
|
||||||
|
throw new Error('Invalid time range specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
timeRange,
|
||||||
|
startDate: range.start.toISO(),
|
||||||
|
endDate: range.end.toISO(),
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = this.redisService._getCacheKey('campaigns', params);
|
||||||
|
let cachedData = null;
|
||||||
|
try {
|
||||||
|
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||||
|
if (cachedData) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[CampaignsService] Cache error:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getCampaigns(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
_transformCampaigns(campaigns) {
|
||||||
|
if (!Array.isArray(campaigns)) {
|
||||||
|
console.warn('[CampaignsService] Campaigns is not an array:', campaigns);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaigns.map(campaign => {
|
||||||
|
try {
|
||||||
|
const stats = campaign.attributes?.campaign_message?.stats || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: campaign.id,
|
||||||
|
name: campaign.attributes?.name || "Unnamed Campaign",
|
||||||
|
subject: campaign.attributes?.campaign_message?.subject || "",
|
||||||
|
send_time: campaign.attributes?.send_time,
|
||||||
|
stats: {
|
||||||
|
delivery_rate: stats.delivery_rate || 0,
|
||||||
|
delivered: stats.delivered || 0,
|
||||||
|
recipients: stats.recipients || 0,
|
||||||
|
open_rate: stats.open_rate || 0,
|
||||||
|
opens_unique: stats.opens_unique || 0,
|
||||||
|
opens: stats.opens || 0,
|
||||||
|
clicks_unique: stats.clicks_unique || 0,
|
||||||
|
click_rate: stats.click_rate || 0,
|
||||||
|
click_to_open_rate: stats.click_to_open_rate || 0,
|
||||||
|
conversion_value: stats.conversion_value || 0,
|
||||||
|
conversion_uniques: stats.conversion_uniques || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CampaignsService] Error transforming campaign:', error, campaign);
|
||||||
|
return {
|
||||||
|
id: campaign.id || 'unknown',
|
||||||
|
name: 'Error Processing Campaign',
|
||||||
|
stats: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export class MetricsService {
|
||||||
|
constructor(apiKey, apiRevision) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.apiRevision = apiRevision;
|
||||||
|
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||||
|
}
|
||||||
|
async getMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/metrics/`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||||
|
'revision': this.apiRevision,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('[MetricsService] API Error:', errorData);
|
||||||
|
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Sort the results by name before returning
|
||||||
|
if (data.data) {
|
||||||
|
data.data.sort((a, b) => a.attributes.name.localeCompare(b.attributes.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MetricsService] Error fetching metrics:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Klaviyo cache wrapper. Was a self-instantiating ioredis client per service in
|
||||||
|
// the standalone klaviyo-server; now accepts an injected client so the merged
|
||||||
|
// dashboard-server shares one connection across all vendors (Phase 4).
|
||||||
|
//
|
||||||
|
// Public surface kept identical to the original so the ~3K LOC of klaviyo
|
||||||
|
// service code (events/campaigns/reporting) needs no other changes:
|
||||||
|
// - get(key)
|
||||||
|
// - set(key, data, ttl)
|
||||||
|
// - _getCacheKey(type, params)
|
||||||
|
// - _getTTL(timeRange)
|
||||||
|
// - getEventData(type, params) / cacheEventData(type, params, data)
|
||||||
|
// - clearCache(params)
|
||||||
|
//
|
||||||
|
// Reads short-circuit to null when the client isn't ready; writes are no-ops.
|
||||||
|
// Same "Redis hiccup → fall through to upstream" behavior as before.
|
||||||
|
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
|
||||||
|
export class RedisService {
|
||||||
|
constructor(redis) {
|
||||||
|
if (!redis) {
|
||||||
|
throw new Error('RedisService requires an ioredis client (Phase 4: injected, no longer self-constructed)');
|
||||||
|
}
|
||||||
|
this.client = redis;
|
||||||
|
this.timeManager = new TimeManager();
|
||||||
|
this.DEFAULT_TTL = 5 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
// ioredis: 'wait' | 'reconnecting' | 'connecting' | 'connect' | 'ready' | 'close' | 'end'
|
||||||
|
return this.client.status === 'ready' || this.client.status === 'connect';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
if (!this.isConnected) return null;
|
||||||
|
try {
|
||||||
|
const data = await this.client.get(key);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error getting data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, data, ttl = this.DEFAULT_TTL) {
|
||||||
|
if (!this.isConnected) return;
|
||||||
|
try {
|
||||||
|
await this.client.setex(key, ttl, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error setting data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCacheKey(type, params = {}) {
|
||||||
|
const {
|
||||||
|
timeRange,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
metricId,
|
||||||
|
metric,
|
||||||
|
daily,
|
||||||
|
cacheKey,
|
||||||
|
isPreviousPeriod,
|
||||||
|
customFilters,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
let key = `klaviyo:${type}`;
|
||||||
|
|
||||||
|
if (type === 'stats:details') {
|
||||||
|
key += `:${metric || 'all'}`;
|
||||||
|
if (daily) key += ':daily';
|
||||||
|
if (customFilters?.length) {
|
||||||
|
const filterHash = customFilters.join('').replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
key += `:${filterHash}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheKey) {
|
||||||
|
key += `:${cacheKey}`;
|
||||||
|
} else if (timeRange) {
|
||||||
|
key += `:${timeRange}`;
|
||||||
|
if (metricId) key += `:${metricId}`;
|
||||||
|
if (isPreviousPeriod) key += ':prev';
|
||||||
|
} else if (startDate && endDate) {
|
||||||
|
key += `:custom:${startDate}:${endDate}`;
|
||||||
|
if (metricId) key += `:${metricId}`;
|
||||||
|
if (isPreviousPeriod) key += ':prev';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['pre_orders', 'local_pickup', 'on_hold'].includes(metric)) {
|
||||||
|
key += `:${metric}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getTTL(timeRange) {
|
||||||
|
const TTL_MAP = {
|
||||||
|
today: 2 * 60,
|
||||||
|
yesterday: 30 * 60,
|
||||||
|
thisWeek: 5 * 60,
|
||||||
|
lastWeek: 60 * 60,
|
||||||
|
thisMonth: 10 * 60,
|
||||||
|
lastMonth: 2 * 60 * 60,
|
||||||
|
last7days: 5 * 60,
|
||||||
|
last30days: 15 * 60,
|
||||||
|
custom: 15 * 60,
|
||||||
|
};
|
||||||
|
return TTL_MAP[timeRange] || this.DEFAULT_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEventData(type, params) {
|
||||||
|
if (!this.isConnected) return null;
|
||||||
|
try {
|
||||||
|
const baseKey = this._getCacheKey('events', params);
|
||||||
|
return await this.get(`${baseKey}:${type}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error getting event data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cacheEventData(type, params, data) {
|
||||||
|
if (!this.isConnected) return;
|
||||||
|
try {
|
||||||
|
const ttl = this._getTTL(params.timeRange);
|
||||||
|
const baseKey = this._getCacheKey('events', params);
|
||||||
|
await this.set(`${baseKey}:${type}`, data, ttl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error caching event data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCache(params = {}) {
|
||||||
|
if (!this.isConnected) return;
|
||||||
|
try {
|
||||||
|
const pattern = this._getCacheKey('events', params) + '*';
|
||||||
|
const keys = await this.client.keys(pattern);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.client.del(...keys);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RedisService] Error clearing cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { TimeManager } from '../../utils/time.utils.js';
|
||||||
|
import { RedisService } from './redis.service.js';
|
||||||
|
|
||||||
|
const METRIC_IDS = {
|
||||||
|
PLACED_ORDER: 'Y8cqcF'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ReportingService {
|
||||||
|
constructor(apiKey, apiRevision, redis) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.apiRevision = apiRevision;
|
||||||
|
this.baseUrl = 'https://a.klaviyo.com/api';
|
||||||
|
this.timeManager = new TimeManager();
|
||||||
|
this.redisService = new RedisService(redis);
|
||||||
|
this._pendingReportRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCampaignReports(params = {}) {
|
||||||
|
try {
|
||||||
|
// Check if there's a pending request
|
||||||
|
if (this._pendingReportRequest) {
|
||||||
|
console.log('[ReportingService] Using pending campaign report request');
|
||||||
|
return this._pendingReportRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get from cache first
|
||||||
|
const cacheKey = this.redisService._getCacheKey('campaign_reports', params);
|
||||||
|
let cachedData = null;
|
||||||
|
try {
|
||||||
|
cachedData = await this.redisService.get(`${cacheKey}:raw`);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log('[ReportingService] Using cached campaign report data');
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[ReportingService] Cache error:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request promise
|
||||||
|
this._pendingReportRequest = (async () => {
|
||||||
|
console.log('[ReportingService] Fetching fresh campaign report data');
|
||||||
|
|
||||||
|
const range = this.timeManager.getDateRange(params.timeRange || 'last30days');
|
||||||
|
|
||||||
|
// Determine which channels to fetch based on params
|
||||||
|
const channelsToFetch = params.channel === 'all' || !params.channel
|
||||||
|
? ['email', 'sms']
|
||||||
|
: [params.channel];
|
||||||
|
|
||||||
|
const allResults = [];
|
||||||
|
|
||||||
|
// Fetch each channel
|
||||||
|
for (const channel of channelsToFetch) {
|
||||||
|
const payload = {
|
||||||
|
data: {
|
||||||
|
type: "campaign-values-report",
|
||||||
|
attributes: {
|
||||||
|
timeframe: {
|
||||||
|
start: range.start.toISO(),
|
||||||
|
end: range.end.toISO()
|
||||||
|
},
|
||||||
|
statistics: [
|
||||||
|
"delivery_rate",
|
||||||
|
"delivered",
|
||||||
|
"recipients",
|
||||||
|
"open_rate",
|
||||||
|
"opens_unique",
|
||||||
|
"opens",
|
||||||
|
"click_rate",
|
||||||
|
"clicks_unique",
|
||||||
|
"click_to_open_rate",
|
||||||
|
"conversion_value",
|
||||||
|
"conversion_uniques"
|
||||||
|
],
|
||||||
|
conversion_metric_id: METRIC_IDS.PLACED_ORDER,
|
||||||
|
filter: `equals(send_channel,"${channel}")`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/campaign-values-reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||||
|
'revision': this.apiRevision
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
console.error('[ReportingService] API Error:', errorData);
|
||||||
|
throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportData = await response.json();
|
||||||
|
console.log(`[ReportingService] Raw ${channel} report data:`, JSON.stringify(reportData, null, 2));
|
||||||
|
|
||||||
|
// Get campaign IDs from the report
|
||||||
|
const campaignIds = reportData.data?.attributes?.results?.map(result =>
|
||||||
|
result.groupings?.campaign_id
|
||||||
|
).filter(Boolean) || [];
|
||||||
|
|
||||||
|
if (campaignIds.length > 0) {
|
||||||
|
// Get campaign details including send time and subject lines
|
||||||
|
const campaignDetails = await this.getCampaignDetails(campaignIds);
|
||||||
|
|
||||||
|
// Process results for this channel
|
||||||
|
const channelResults = reportData.data.attributes.results.map(result => {
|
||||||
|
const campaignId = result.groupings.campaign_id;
|
||||||
|
const details = campaignDetails.find(detail => detail.id === campaignId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: campaignId,
|
||||||
|
name: details.attributes.name,
|
||||||
|
subject: details.attributes.subject,
|
||||||
|
send_time: details.attributes.send_time,
|
||||||
|
channel: channel, // Use the channel we're currently processing
|
||||||
|
stats: {
|
||||||
|
delivery_rate: result.statistics.delivery_rate,
|
||||||
|
delivered: result.statistics.delivered,
|
||||||
|
recipients: result.statistics.recipients,
|
||||||
|
open_rate: result.statistics.open_rate,
|
||||||
|
opens_unique: result.statistics.opens_unique,
|
||||||
|
opens: result.statistics.opens,
|
||||||
|
click_rate: result.statistics.click_rate,
|
||||||
|
clicks_unique: result.statistics.clicks_unique,
|
||||||
|
click_to_open_rate: result.statistics.click_to_open_rate,
|
||||||
|
conversion_value: result.statistics.conversion_value,
|
||||||
|
conversion_uniques: result.statistics.conversion_uniques
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
allResults.push(...channelResults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort all results by date
|
||||||
|
const enrichedData = {
|
||||||
|
data: allResults.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.send_time);
|
||||||
|
const dateB = new Date(b.send_time);
|
||||||
|
return dateB - dateA; // Sort by date descending
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[ReportingService] Enriched data:', JSON.stringify(enrichedData, null, 2));
|
||||||
|
|
||||||
|
// Cache the enriched response for 10 minutes
|
||||||
|
try {
|
||||||
|
await this.redisService.set(`${cacheKey}:raw`, enrichedData, 600);
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.warn('[ReportingService] Cache set error:', cacheError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichedData;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await this._pendingReportRequest;
|
||||||
|
this._pendingReportRequest = null;
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ReportingService] Error fetching campaign reports:', error);
|
||||||
|
this._pendingReportRequest = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCampaignDetails(campaignIds = []) {
|
||||||
|
if (!Array.isArray(campaignIds) || campaignIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchWithTimeout = async (campaignId, retries = 3) => {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/campaigns/${campaignId}?include=campaign-messages`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Klaviyo-API-Key ${this.apiKey}`,
|
||||||
|
'revision': this.apiRevision
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch campaign ${campaignId}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.data) {
|
||||||
|
throw new Error(`Invalid response for campaign ${campaignId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = data.included?.find(item => item.type === 'campaign-message');
|
||||||
|
|
||||||
|
console.log('[ReportingService] Campaign details for ID:', campaignId, {
|
||||||
|
send_channel: data.data.attributes.send_channel,
|
||||||
|
raw_attributes: data.data.attributes
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.data.id,
|
||||||
|
type: data.data.type,
|
||||||
|
attributes: {
|
||||||
|
...data.data.attributes,
|
||||||
|
name: data.data.attributes.name,
|
||||||
|
send_time: data.data.attributes.send_time,
|
||||||
|
subject: message?.attributes?.content?.subject,
|
||||||
|
send_channel: data.data.attributes.send_channel || 'email'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (i === retries - 1) throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // Exponential backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process in smaller chunks to avoid overwhelming the API
|
||||||
|
const chunkSize = 10;
|
||||||
|
const campaignDetails = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < campaignIds.length; i += chunkSize) {
|
||||||
|
const chunk = campaignIds.slice(i, i + chunkSize);
|
||||||
|
const results = await Promise.all(
|
||||||
|
chunk.map(id => fetchWithTimeout(id).catch(error => {
|
||||||
|
console.error(`Failed to fetch campaign ${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
campaignDetails.push(...results.filter(Boolean));
|
||||||
|
|
||||||
|
if (i + chunkSize < campaignIds.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay between chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return campaignDetails;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Meta (Facebook Ads) service — ESM conversion of meta-server/services/meta.service.js.
|
||||||
|
// No Redis caching (matches the original — Meta calls are cheap-enough; reach/spend
|
||||||
|
// rolls over once per request). Uses axios.
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const version = process.env.META_API_VERSION || 'v21.0';
|
||||||
|
return {
|
||||||
|
baseUrl: `https://graph.facebook.com/${version}`,
|
||||||
|
accessToken: process.env.META_ACCESS_TOKEN,
|
||||||
|
adAccountId: process.env.META_AD_ACCOUNT_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function metaApiRequest(endpoint, params = {}) {
|
||||||
|
const { baseUrl, accessToken } = getConfig();
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||||
|
params: {
|
||||||
|
access_token: accessToken,
|
||||||
|
time_zone: 'America/New_York',
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Meta API Error:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
endpoint,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCampaigns(since, until) {
|
||||||
|
const { adAccountId } = getConfig();
|
||||||
|
const campaigns = await metaApiRequest(`act_${adAccountId}/campaigns`, {
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'status',
|
||||||
|
'objective',
|
||||||
|
'daily_budget',
|
||||||
|
'lifetime_budget',
|
||||||
|
'adsets{daily_budget,lifetime_budget}',
|
||||||
|
`insights.time_range({'since':'${since}','until':'${until}'}).level(campaign){
|
||||||
|
spend,
|
||||||
|
impressions,
|
||||||
|
clicks,
|
||||||
|
ctr,
|
||||||
|
reach,
|
||||||
|
frequency,
|
||||||
|
cpm,
|
||||||
|
cpc,
|
||||||
|
actions,
|
||||||
|
action_values,
|
||||||
|
cost_per_action_type
|
||||||
|
}`,
|
||||||
|
].join(','),
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return campaigns.data.filter((c) => c.insights?.data?.[0]?.spend > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAccountInsights(since, until) {
|
||||||
|
const { adAccountId } = getConfig();
|
||||||
|
const accountInsights = await metaApiRequest(`act_${adAccountId}/insights`, {
|
||||||
|
fields: 'reach,spend,impressions,clicks,ctr,cpm,actions,action_values',
|
||||||
|
time_range: JSON.stringify({ since, until }),
|
||||||
|
});
|
||||||
|
return accountInsights.data[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampaignBudget(campaignId, budget) {
|
||||||
|
const { baseUrl, accessToken } = getConfig();
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${baseUrl}/${campaignId}`, {
|
||||||
|
access_token: accessToken,
|
||||||
|
daily_budget: budget * 100, // dollars → cents
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update campaign budget error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCampaignStatus(campaignId, action) {
|
||||||
|
const { baseUrl, accessToken } = getConfig();
|
||||||
|
try {
|
||||||
|
const status = action === 'pause' ? 'PAUSED' : 'ACTIVE';
|
||||||
|
const response = await axios.post(`${baseUrl}/${campaignId}`, {
|
||||||
|
access_token: accessToken,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update campaign status error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Typeform service — ESM conversion of typeform-server/services/typeform.service.js.
|
||||||
|
// Phase 4: accepts injected ioredis client. node-redis v4 set syntax `{ EX: 300 }`
|
||||||
|
// translated to ioredis `setex(key, 300, val)`.
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export class TypeformService {
|
||||||
|
constructor(redis) {
|
||||||
|
if (!redis) {
|
||||||
|
throw new Error('TypeformService requires an ioredis client (Phase 4: injected)');
|
||||||
|
}
|
||||||
|
this.redis = redis;
|
||||||
|
|
||||||
|
const token = process.env.TYPEFORM_ACCESS_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[Typeform] TYPEFORM_ACCESS_TOKEN not set — all calls will 401');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiClient = axios.create({
|
||||||
|
baseURL: 'https://api.typeform.com',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get _redisReady() {
|
||||||
|
return this.redis.status === 'ready' || this.redis.status === 'connect';
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cacheGet(key) {
|
||||||
|
if (!this._redisReady) return null;
|
||||||
|
try {
|
||||||
|
const raw = await this.redis.get(key);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Typeform] cache get failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _cacheSet(key, value, ttlSec) {
|
||||||
|
if (!this._redisReady) return;
|
||||||
|
try {
|
||||||
|
await this.redis.setex(key, ttlSec, JSON.stringify(value));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Typeform] cache set failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFormResponses(formId, params = {}) {
|
||||||
|
const cacheKey = `typeform:responses:${formId}:${JSON.stringify(params)}`;
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await this.apiClient.get(`/forms/${formId}/responses`, { params });
|
||||||
|
const data = response.data;
|
||||||
|
await this._cacheSet(cacheKey, data, 300);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFormInsights(formId) {
|
||||||
|
const cacheKey = `typeform:insights:${formId}`;
|
||||||
|
const cached = await this._cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await this.apiClient.get(`/insights/${formId}/summary`);
|
||||||
|
const data = response.data;
|
||||||
|
await this._cacheSet(cacheKey, data, 300);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFormResponsesWithFilters(formId, { since, until, pageSize = 25, ...otherParams } = {}) {
|
||||||
|
const params = { page_size: pageSize, ...otherParams };
|
||||||
|
if (since) params.since = new Date(since).toISOString();
|
||||||
|
if (until) params.until = new Date(until).toISOString();
|
||||||
|
return this.getFormResponses(formId, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export class TimeManager {
|
||||||
|
constructor(dayStartHour = 1) {
|
||||||
|
this.timezone = 'America/New_York';
|
||||||
|
this.dayStartHour = dayStartHour; // Hour (0-23) when the business day starts
|
||||||
|
this.weekStartDay = 7; // 7 = Sunday in Luxon
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the start of the current business day
|
||||||
|
* If current time is before dayStartHour, return previous day at dayStartHour
|
||||||
|
*/
|
||||||
|
getDayStart(dt = this.getNow()) {
|
||||||
|
if (!dt.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid datetime provided to getDayStart");
|
||||||
|
return this.getNow();
|
||||||
|
}
|
||||||
|
const dayStart = dt.set({ hour: this.dayStartHour, minute: 0, second: 0, millisecond: 0 });
|
||||||
|
return dt.hour < this.dayStartHour ? dayStart.minus({ days: 1 }) : dayStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the end of the current business day
|
||||||
|
* End is defined as dayStartHour - 1 minute on the next day
|
||||||
|
*/
|
||||||
|
getDayEnd(dt = this.getNow()) {
|
||||||
|
if (!dt.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid datetime provided to getDayEnd");
|
||||||
|
return this.getNow();
|
||||||
|
}
|
||||||
|
const nextDay = this.getDayStart(dt).plus({ days: 1 });
|
||||||
|
return nextDay.minus({ minutes: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the start of the week containing the given date
|
||||||
|
* Aligns with custom day start time and starts on Sunday
|
||||||
|
*/
|
||||||
|
getWeekStart(dt = this.getNow()) {
|
||||||
|
if (!dt.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid datetime provided to getWeekStart");
|
||||||
|
return this.getNow();
|
||||||
|
}
|
||||||
|
// Set to start of week (Sunday) and adjust hour
|
||||||
|
const weekStart = dt.set({ weekday: this.weekStartDay }).startOf('day');
|
||||||
|
// If the week start time would be after the given time, go back a week
|
||||||
|
if (weekStart > dt) {
|
||||||
|
return weekStart.minus({ weeks: 1 }).set({ hour: this.dayStartHour });
|
||||||
|
}
|
||||||
|
return weekStart.set({ hour: this.dayStartHour });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert any date input to a Luxon DateTime in Eastern time
|
||||||
|
*/
|
||||||
|
toDateTime(date) {
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
if (date instanceof DateTime) {
|
||||||
|
return date.setZone(this.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an ISO string or Date object, parse it
|
||||||
|
const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
|
||||||
|
if (!dt.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid date input:", date);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dt.setZone(this.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for API requests (UTC ISO string)
|
||||||
|
*/
|
||||||
|
formatForAPI(date) {
|
||||||
|
if (!date) return null;
|
||||||
|
|
||||||
|
// Parse the input date
|
||||||
|
const dt = this.toDateTime(date);
|
||||||
|
if (!dt || !dt.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid date for API:", date);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to UTC for API request
|
||||||
|
const utc = dt.toUTC();
|
||||||
|
|
||||||
|
console.log("[TimeManager] API date conversion:", {
|
||||||
|
input: date,
|
||||||
|
eastern: dt.toISO(),
|
||||||
|
utc: utc.toISO(),
|
||||||
|
offset: dt.offset
|
||||||
|
});
|
||||||
|
|
||||||
|
return utc.toISO();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display (in Eastern time)
|
||||||
|
*/
|
||||||
|
formatForDisplay(date) {
|
||||||
|
const dt = this.toDateTime(date);
|
||||||
|
if (!dt || !dt.isValid) return '';
|
||||||
|
return dt.toFormat('LLL d, yyyy h:mm a');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a date range is valid
|
||||||
|
*/
|
||||||
|
isValidDateRange(start, end) {
|
||||||
|
const startDt = this.toDateTime(start);
|
||||||
|
const endDt = this.toDateTime(end);
|
||||||
|
return startDt && endDt && endDt > startDt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current time in Eastern timezone
|
||||||
|
*/
|
||||||
|
getNow() {
|
||||||
|
return DateTime.now().setZone(this.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a date range for the last N hours
|
||||||
|
*/
|
||||||
|
getLastNHours(hours) {
|
||||||
|
const now = this.getNow();
|
||||||
|
return {
|
||||||
|
start: now.minus({ hours }),
|
||||||
|
end: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a date range for the last N days
|
||||||
|
* Aligns with custom day start time
|
||||||
|
*/
|
||||||
|
getLastNDays(days) {
|
||||||
|
const now = this.getNow();
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
return {
|
||||||
|
start: dayStart.minus({ days }),
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a date range for a specific time period
|
||||||
|
* All ranges align with custom day start time
|
||||||
|
*/
|
||||||
|
getDateRange(period) {
|
||||||
|
const now = this.getNow();
|
||||||
|
|
||||||
|
// Normalize period to handle both 'last' and 'previous' prefixes
|
||||||
|
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
||||||
|
|
||||||
|
switch (normalizedPeriod) {
|
||||||
|
case 'custom': {
|
||||||
|
// Custom ranges are handled separately via getCustomRange
|
||||||
|
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'today': {
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
return {
|
||||||
|
start: dayStart,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
const yesterday = now.minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
start: this.getDayStart(yesterday),
|
||||||
|
end: this.getDayEnd(yesterday)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last7days': {
|
||||||
|
// For last 7 days, we want to include today and the previous 6 days
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const weekStart = dayStart.minus({ days: 6 });
|
||||||
|
return {
|
||||||
|
start: weekStart,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last30days': {
|
||||||
|
// Include today and previous 29 days
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const monthStart = dayStart.minus({ days: 29 });
|
||||||
|
return {
|
||||||
|
start: monthStart,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
// Include today and previous 89 days
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const start = dayStart.minus({ days: 89 });
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisWeek': {
|
||||||
|
// Get the start of the week (Sunday) with custom hour
|
||||||
|
const weekStart = this.getWeekStart(now);
|
||||||
|
return {
|
||||||
|
start: weekStart,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastWeek': {
|
||||||
|
const lastWeek = now.minus({ weeks: 1 });
|
||||||
|
const weekStart = this.getWeekStart(lastWeek);
|
||||||
|
const weekEnd = weekStart.plus({ days: 6 }); // 6 days after start = Saturday
|
||||||
|
return {
|
||||||
|
start: weekStart,
|
||||||
|
end: this.getDayEnd(weekEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisMonth': {
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const monthStart = dayStart.startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
return {
|
||||||
|
start: monthStart,
|
||||||
|
end: this.getDayEnd(now)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastMonth': {
|
||||||
|
const lastMonth = now.minus({ months: 1 });
|
||||||
|
const monthStart = lastMonth.startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
start: monthStart,
|
||||||
|
end: this.getDayEnd(monthEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.warn(`[TimeManager] Unknown period: ${period}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration in milliseconds to a human-readable string
|
||||||
|
*/
|
||||||
|
formatDuration(ms) {
|
||||||
|
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative time string (e.g., "2 hours ago")
|
||||||
|
*/
|
||||||
|
getRelativeTime(date) {
|
||||||
|
const dt = this.toDateTime(date);
|
||||||
|
if (!dt) return '';
|
||||||
|
return dt.toRelative();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a custom date range using exact dates and times provided
|
||||||
|
* @param {string} startDate - ISO string or Date for range start
|
||||||
|
* @param {string} endDate - ISO string or Date for range end
|
||||||
|
* @returns {Object} Object with start and end DateTime objects
|
||||||
|
*/
|
||||||
|
getCustomRange(startDate, endDate) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
console.error("[TimeManager] Custom range requires both start and end dates");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = this.toDateTime(startDate);
|
||||||
|
const end = this.toDateTime(endDate);
|
||||||
|
|
||||||
|
if (!start || !end || !start.isValid || !end.isValid) {
|
||||||
|
console.error("[TimeManager] Invalid dates provided for custom range");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the range
|
||||||
|
if (end < start) {
|
||||||
|
console.error("[TimeManager] End date must be after start date");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the previous period's date range based on the current period
|
||||||
|
* @param {string} period - The current period
|
||||||
|
* @param {DateTime} now - The current datetime (optional)
|
||||||
|
* @returns {Object} Object with start and end DateTime objects
|
||||||
|
*/
|
||||||
|
getPreviousPeriod(period, now = this.getNow()) {
|
||||||
|
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
||||||
|
|
||||||
|
switch (normalizedPeriod) {
|
||||||
|
case 'today': {
|
||||||
|
const yesterday = now.minus({ days: 1 });
|
||||||
|
return {
|
||||||
|
start: this.getDayStart(yesterday),
|
||||||
|
end: this.getDayEnd(yesterday)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
const twoDaysAgo = now.minus({ days: 2 });
|
||||||
|
return {
|
||||||
|
start: this.getDayStart(twoDaysAgo),
|
||||||
|
end: this.getDayEnd(twoDaysAgo)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last7days': {
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const currentStart = dayStart.minus({ days: 6 });
|
||||||
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = prevEnd.minus({ days: 6 });
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last30days': {
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const currentStart = dayStart.minus({ days: 29 });
|
||||||
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = prevEnd.minus({ days: 29 });
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'last90days': {
|
||||||
|
const dayStart = this.getDayStart(now);
|
||||||
|
const currentStart = dayStart.minus({ days: 89 });
|
||||||
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = prevEnd.minus({ days: 89 });
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisWeek': {
|
||||||
|
const weekStart = this.getWeekStart(now);
|
||||||
|
const prevEnd = weekStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = this.getWeekStart(prevEnd);
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastWeek': {
|
||||||
|
const lastWeekStart = this.getWeekStart(now.minus({ weeks: 1 }));
|
||||||
|
const prevEnd = lastWeekStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = this.getWeekStart(prevEnd);
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'thisMonth': {
|
||||||
|
const monthStart = now.startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
const prevEnd = monthStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'lastMonth': {
|
||||||
|
const lastMonthStart = now.minus({ months: 1 }).startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
const prevEnd = lastMonthStart.minus({ milliseconds: 1 });
|
||||||
|
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
|
||||||
|
return {
|
||||||
|
start: prevStart,
|
||||||
|
end: prevEnd
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.warn(`[TimeManager] No previous period defined for: ${period}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEventsByInterval(events, interval = 'day', property = null) {
|
||||||
|
if (!events?.length) return [];
|
||||||
|
|
||||||
|
const groupedData = new Map();
|
||||||
|
const now = DateTime.now().setZone('America/New_York');
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const datetime = DateTime.fromISO(event.attributes.datetime);
|
||||||
|
let groupKey;
|
||||||
|
|
||||||
|
switch (interval) {
|
||||||
|
case 'hour':
|
||||||
|
groupKey = datetime.startOf('hour').toISO();
|
||||||
|
break;
|
||||||
|
case 'day':
|
||||||
|
groupKey = datetime.startOf('day').toISO();
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
groupKey = datetime.startOf('week').toISO();
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
groupKey = datetime.startOf('month').toISO();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
groupKey = datetime.startOf('day').toISO();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingGroup = groupedData.get(groupKey) || {
|
||||||
|
datetime: groupKey,
|
||||||
|
count: 0,
|
||||||
|
value: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
existingGroup.count++;
|
||||||
|
|
||||||
|
if (property) {
|
||||||
|
// Extract property value from event
|
||||||
|
const props = event.attributes?.event_properties || event.attributes?.properties || {};
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
if (property === '$value') {
|
||||||
|
// Special case for $value - use event value
|
||||||
|
value = Number(event.attributes?.value || 0);
|
||||||
|
} else {
|
||||||
|
// Otherwise get from properties
|
||||||
|
value = Number(props[property] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingGroup.value = (existingGroup.value || 0) + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedData.set(groupKey, existingGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by datetime
|
||||||
|
return Array.from(groupedData.values())
|
||||||
|
.sort((a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Daily Deals schema for local PostgreSQL
|
||||||
|
-- Synced from production MySQL product_daily_deals + product_current_prices
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_daily_deals (
|
||||||
|
deal_id serial PRIMARY KEY,
|
||||||
|
deal_date date NOT NULL,
|
||||||
|
pid bigint NOT NULL,
|
||||||
|
price_id bigint NOT NULL,
|
||||||
|
-- Denormalized from product_current_prices so we don't need to sync that whole table
|
||||||
|
deal_price numeric(10,3),
|
||||||
|
created_at timestamptz DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_daily_deals_pid FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_deals_date ON product_daily_deals(deal_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_daily_deals_pid ON product_daily_deals(pid);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_deals_unique ON product_daily_deals(deal_date, pid);
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
-- Custom PostgreSQL functions used by the metrics pipeline
|
||||||
|
-- These must exist in the database before running calculate-metrics-new.js
|
||||||
|
--
|
||||||
|
-- To install/update: psql -d inventory_db -f functions.sql
|
||||||
|
-- All functions use CREATE OR REPLACE so they are safe to re-run.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- safe_divide: Division helper that returns a default value instead of erroring
|
||||||
|
-- on NULL or zero denominators.
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.safe_divide(
|
||||||
|
numerator numeric,
|
||||||
|
denominator numeric,
|
||||||
|
default_value numeric DEFAULT NULL::numeric
|
||||||
|
)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
IF denominator IS NULL OR denominator = 0 THEN
|
||||||
|
RETURN default_value;
|
||||||
|
ELSE
|
||||||
|
RETURN numerator / denominator;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- std_numeric: Standardized rounding helper for consistent numeric precision.
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.std_numeric(
|
||||||
|
value numeric,
|
||||||
|
precision_digits integer DEFAULT 2
|
||||||
|
)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
IF value IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
ELSE
|
||||||
|
RETURN ROUND(value, precision_digits);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- calculate_sales_velocity: Daily sales velocity adjusted for stockout days.
|
||||||
|
-- Ensures at least 14-day denominator for products with sales to avoid
|
||||||
|
-- inflated velocity from short windows.
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.calculate_sales_velocity(
|
||||||
|
sales_30d integer,
|
||||||
|
stockout_days_30d integer
|
||||||
|
)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
RETURN sales_30d /
|
||||||
|
NULLIF(
|
||||||
|
GREATEST(
|
||||||
|
30.0 - stockout_days_30d,
|
||||||
|
CASE
|
||||||
|
WHEN sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
|
||||||
|
ELSE 30.0 -- If no sales, use full period
|
||||||
|
END
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- get_weighted_avg_cost: Weighted average cost from receivings up to a given date.
|
||||||
|
-- Uses all non-canceled receivings (no row limit) weighted by quantity.
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_weighted_avg_cost(
|
||||||
|
p_pid bigint,
|
||||||
|
p_date date
|
||||||
|
)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
weighted_cost NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN SUM(qty_each) > 0 THEN SUM(cost_each * qty_each) / SUM(qty_each)
|
||||||
|
ELSE NULL
|
||||||
|
END INTO weighted_cost
|
||||||
|
FROM receivings
|
||||||
|
WHERE pid = p_pid
|
||||||
|
AND received_date <= p_date
|
||||||
|
AND status != 'canceled';
|
||||||
|
|
||||||
|
RETURN weighted_cost;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- classify_demand_pattern: Classifies demand based on average demand and
|
||||||
|
-- coefficient of variation (CV). Standard inventory classification:
|
||||||
|
-- zero: no demand
|
||||||
|
-- stable: CV <= 0.2 (predictable, easy to forecast)
|
||||||
|
-- variable: CV <= 0.5 (some variability, still forecastable)
|
||||||
|
-- sporadic: low volume + high CV (intermittent demand)
|
||||||
|
-- lumpy: high volume + high CV (unpredictable bursts)
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.classify_demand_pattern(
|
||||||
|
avg_demand numeric,
|
||||||
|
cv numeric
|
||||||
|
)
|
||||||
|
RETURNS character varying
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $function$
|
||||||
|
BEGIN
|
||||||
|
IF avg_demand IS NULL OR cv IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
ELSIF avg_demand = 0 THEN
|
||||||
|
RETURN 'zero';
|
||||||
|
ELSIF cv <= 0.2 THEN
|
||||||
|
RETURN 'stable';
|
||||||
|
ELSIF cv <= 0.5 THEN
|
||||||
|
RETURN 'variable';
|
||||||
|
ELSIF avg_demand < 1.0 THEN
|
||||||
|
RETURN 'sporadic';
|
||||||
|
ELSE
|
||||||
|
RETURN 'lumpy';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- detect_seasonal_pattern: Detects seasonality by comparing monthly average
|
||||||
|
-- sales across the last 12 months. Uses coefficient of variation across months
|
||||||
|
-- and peak-to-average ratio to classify patterns.
|
||||||
|
--
|
||||||
|
-- Returns:
|
||||||
|
-- seasonal_pattern: 'none', 'moderate', or 'strong'
|
||||||
|
-- seasonality_index: peak month avg / overall avg * 100 (100 = no seasonality)
|
||||||
|
-- peak_season: name of peak month (e.g. 'January'), or NULL if none
|
||||||
|
-- =============================================================================
|
||||||
|
CREATE OR REPLACE FUNCTION public.detect_seasonal_pattern(p_pid bigint)
|
||||||
|
RETURNS TABLE(seasonal_pattern character varying, seasonality_index numeric, peak_season character varying)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
AS $function$
|
||||||
|
DECLARE
|
||||||
|
v_monthly_cv NUMERIC;
|
||||||
|
v_max_month_avg NUMERIC;
|
||||||
|
v_overall_avg NUMERIC;
|
||||||
|
v_monthly_stddev NUMERIC;
|
||||||
|
v_peak_month_num INT;
|
||||||
|
v_data_months INT;
|
||||||
|
v_seasonality_index NUMERIC;
|
||||||
|
v_seasonal_pattern VARCHAR;
|
||||||
|
v_peak_season VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
-- Gather monthly average sales and peak month in a single query
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
AVG(month_avg),
|
||||||
|
STDDEV(month_avg),
|
||||||
|
MAX(month_avg),
|
||||||
|
(ARRAY_AGG(mo ORDER BY month_avg DESC))[1]::INT
|
||||||
|
INTO v_data_months, v_overall_avg, v_monthly_stddev, v_max_month_avg, v_peak_month_num
|
||||||
|
FROM (
|
||||||
|
SELECT EXTRACT(MONTH FROM snapshot_date) AS mo, AVG(units_sold) AS month_avg
|
||||||
|
FROM daily_product_snapshots
|
||||||
|
WHERE pid = p_pid AND snapshot_date >= CURRENT_DATE - INTERVAL '365 days'
|
||||||
|
GROUP BY EXTRACT(MONTH FROM snapshot_date)
|
||||||
|
) monthly;
|
||||||
|
|
||||||
|
-- Need at least 3 months of data for meaningful seasonality detection
|
||||||
|
IF v_data_months < 3 OR v_overall_avg IS NULL OR v_overall_avg = 0 THEN
|
||||||
|
RETURN QUERY SELECT 'none'::VARCHAR, 100::NUMERIC, NULL::VARCHAR;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- CV of monthly averages
|
||||||
|
v_monthly_cv := v_monthly_stddev / v_overall_avg;
|
||||||
|
|
||||||
|
-- Seasonality index: peak month avg / overall avg * 100
|
||||||
|
v_seasonality_index := ROUND((v_max_month_avg / v_overall_avg * 100)::NUMERIC, 2);
|
||||||
|
|
||||||
|
IF v_monthly_cv > 0.5 AND v_seasonality_index > 150 THEN
|
||||||
|
v_seasonal_pattern := 'strong';
|
||||||
|
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||||
|
ELSIF v_monthly_cv > 0.3 AND v_seasonality_index > 120 THEN
|
||||||
|
v_seasonal_pattern := 'moderate';
|
||||||
|
v_peak_season := TRIM(TO_CHAR(TO_DATE(v_peak_month_num::TEXT, 'MM'), 'Month'));
|
||||||
|
ELSE
|
||||||
|
v_seasonal_pattern := 'none';
|
||||||
|
v_peak_season := NULL;
|
||||||
|
v_seasonality_index := 100;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_seasonal_pattern, v_seasonality_index, v_peak_season;
|
||||||
|
END;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- category_hierarchy: Materialized view providing a recursive category tree
|
||||||
|
-- with ancestor paths for efficient rollup queries.
|
||||||
|
--
|
||||||
|
-- Refresh after category changes: REFRESH MATERIALIZED VIEW category_hierarchy;
|
||||||
|
-- =============================================================================
|
||||||
|
-- DROP MATERIALIZED VIEW IF EXISTS category_hierarchy;
|
||||||
|
-- CREATE MATERIALIZED VIEW category_hierarchy AS
|
||||||
|
-- WITH RECURSIVE cat_tree AS (
|
||||||
|
-- SELECT cat_id, name, type, parent_id,
|
||||||
|
-- cat_id AS root_id, 0 AS level, ARRAY[cat_id] AS path
|
||||||
|
-- FROM categories
|
||||||
|
-- WHERE parent_id IS NULL
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT c.cat_id, c.name, c.type, c.parent_id,
|
||||||
|
-- ct.root_id, ct.level + 1, ct.path || c.cat_id
|
||||||
|
-- FROM categories c
|
||||||
|
-- JOIN cat_tree ct ON c.parent_id = ct.cat_id
|
||||||
|
-- )
|
||||||
|
-- SELECT cat_id, name, type, parent_id, root_id, level, path,
|
||||||
|
-- (SELECT array_agg(unnest ORDER BY unnest DESC)
|
||||||
|
-- FROM unnest(cat_tree.path) unnest
|
||||||
|
-- WHERE unnest <> cat_tree.cat_id) AS ancestor_ids
|
||||||
|
-- FROM cat_tree;
|
||||||
|
--
|
||||||
|
-- CREATE UNIQUE INDEX ON category_hierarchy (cat_id);
|
||||||
@@ -80,7 +80,6 @@ CREATE TABLE public.product_metrics (
|
|||||||
current_price NUMERIC(10, 2),
|
current_price NUMERIC(10, 2),
|
||||||
current_regular_price NUMERIC(10, 2),
|
current_regular_price NUMERIC(10, 2),
|
||||||
current_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
current_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
||||||
current_landing_cost_price NUMERIC(10, 4), -- Increased precision for cost
|
|
||||||
current_stock INT NOT NULL DEFAULT 0,
|
current_stock INT NOT NULL DEFAULT 0,
|
||||||
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
current_stock_cost NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||||
current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
current_stock_retail NUMERIC(14, 4) NOT NULL DEFAULT 0.00,
|
||||||
@@ -156,9 +155,9 @@ CREATE TABLE public.product_metrics (
|
|||||||
days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units
|
days_of_stock_closing_stock NUMERIC(10, 2), -- lead_time_closing_stock - days_of_stock_forecast_units
|
||||||
replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty
|
replenishment_needed_raw NUMERIC(10, 2), -- planning_period_forecast_units + config_safety_stock - current_stock - on_order_qty
|
||||||
replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw))
|
replenishment_units INT, -- CEILING(GREATEST(0, replenishment_needed_raw))
|
||||||
replenishment_cost NUMERIC(14, 4), -- replenishment_units * COALESCE(current_landing_cost_price, current_cost_price)
|
replenishment_cost NUMERIC(14, 4), -- replenishment_units * current_cost_price
|
||||||
replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price
|
replenishment_retail NUMERIC(14, 4), -- replenishment_units * current_price
|
||||||
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - COALESCE(current_landing_cost_price, current_cost_price))
|
replenishment_profit NUMERIC(14, 4), -- replenishment_units * (current_price - current_cost_price)
|
||||||
to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units
|
to_order_units INT, -- Apply MOQ/UOM logic to replenishment_units
|
||||||
forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
|
forecast_lost_sales_units NUMERIC(10, 2), -- GREATEST(0, -lead_time_closing_stock)
|
||||||
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price
|
forecast_lost_revenue NUMERIC(14, 4), -- forecast_lost_sales_units * current_price
|
||||||
@@ -167,7 +166,7 @@ CREATE TABLE public.product_metrics (
|
|||||||
sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily
|
sells_out_in_days NUMERIC(10, 1), -- (current_stock + on_order_qty) / sales_velocity_daily
|
||||||
replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time
|
replenish_date DATE, -- Calc based on when stock hits safety stock minus lead time
|
||||||
overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units)
|
overstocked_units INT, -- GREATEST(0, current_stock - config_safety_stock - planning_period_forecast_units)
|
||||||
overstocked_cost NUMERIC(14, 4), -- overstocked_units * COALESCE(current_landing_cost_price, current_cost_price)
|
overstocked_cost NUMERIC(14, 4), -- overstocked_units * current_cost_price
|
||||||
overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
|
overstocked_retail NUMERIC(14, 4), -- overstocked_units * current_price
|
||||||
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status
|
is_old_stock BOOLEAN, -- Based on age, last sold, last received, on_order status
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: Add date_online and shop_score columns to products table
|
||||||
|
-- These fields are imported from production to improve newsletter recommendation accuracy:
|
||||||
|
-- date_online = products.date_ol in production (date product went live on the shop)
|
||||||
|
-- shop_score = products.score in production (sales-based popularity score)
|
||||||
|
--
|
||||||
|
-- After running this migration, do a full (non-incremental) import to backfill:
|
||||||
|
-- INCREMENTAL_UPDATE=false node scripts/import-from-prod.js
|
||||||
|
|
||||||
|
-- Add date_online column (production: products.date_ol)
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS date_online TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- Add shop_score column (production: products.score)
|
||||||
|
-- Using NUMERIC(10,2) to preserve the decimal precision from production
|
||||||
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_score NUMERIC(10, 2) DEFAULT 0;
|
||||||
|
|
||||||
|
-- If shop_score was previously created as INTEGER, convert it
|
||||||
|
ALTER TABLE products ALTER COLUMN shop_score TYPE NUMERIC(10, 2);
|
||||||
|
|
||||||
|
-- Index on date_online for the newsletter "new products" filter
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_date_online ON products(date_online);
|
||||||
@@ -21,6 +21,7 @@ CREATE TABLE products (
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
sku TEXT NOT NULL,
|
sku TEXT NOT NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE,
|
created_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
date_online TIMESTAMP WITH TIME ZONE,
|
||||||
first_received TIMESTAMP WITH TIME ZONE,
|
first_received TIMESTAMP WITH TIME ZONE,
|
||||||
stock_quantity INTEGER DEFAULT 0,
|
stock_quantity INTEGER DEFAULT 0,
|
||||||
preorder_count INTEGER DEFAULT 0,
|
preorder_count INTEGER DEFAULT 0,
|
||||||
@@ -28,7 +29,6 @@ CREATE TABLE products (
|
|||||||
price NUMERIC(14, 4) NOT NULL,
|
price NUMERIC(14, 4) NOT NULL,
|
||||||
regular_price NUMERIC(14, 4) NOT NULL,
|
regular_price NUMERIC(14, 4) NOT NULL,
|
||||||
cost_price NUMERIC(14, 4),
|
cost_price NUMERIC(14, 4),
|
||||||
landing_cost_price NUMERIC(14, 4),
|
|
||||||
barcode TEXT,
|
barcode TEXT,
|
||||||
harmonized_tariff_code TEXT,
|
harmonized_tariff_code TEXT,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE,
|
updated_at TIMESTAMP WITH TIME ZONE,
|
||||||
@@ -63,6 +63,7 @@ CREATE TABLE products (
|
|||||||
baskets INTEGER DEFAULT 0,
|
baskets INTEGER DEFAULT 0,
|
||||||
notifies INTEGER DEFAULT 0,
|
notifies INTEGER DEFAULT 0,
|
||||||
date_last_sold DATE,
|
date_last_sold DATE,
|
||||||
|
shop_score NUMERIC(10, 2) DEFAULT 0,
|
||||||
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (pid)
|
PRIMARY KEY (pid)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration: Create import_sessions table
|
||||||
|
-- Run this against your PostgreSQL database
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS import_sessions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name VARCHAR(255), -- NULL for unnamed/autosave sessions
|
||||||
|
current_step VARCHAR(50) NOT NULL, -- 'validation' | 'imageUpload'
|
||||||
|
data JSONB NOT NULL, -- Product rows
|
||||||
|
product_images JSONB, -- Image assignments
|
||||||
|
global_selections JSONB, -- Supplier, company, line, subline
|
||||||
|
validation_state JSONB, -- Errors, UPC status, generated item numbers
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ensure only one unnamed session per user (autosave slot)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_unnamed_session_per_user
|
||||||
|
ON import_sessions (user_id)
|
||||||
|
WHERE name IS NULL;
|
||||||
|
|
||||||
|
-- Index for fast user lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id
|
||||||
|
ON import_sessions (user_id);
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON TABLE import_sessions IS 'Stores in-progress product import sessions for users';
|
||||||
|
COMMENT ON COLUMN import_sessions.name IS 'Session name - NULL indicates the single unnamed/autosave session per user';
|
||||||
|
COMMENT ON COLUMN import_sessions.current_step IS 'Which step the user was on: validation or imageUpload';
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Migration: Make AI prompts extensible with is_singleton column
|
||||||
|
-- Date: 2024-01-19
|
||||||
|
-- Description: Removes hardcoded prompt_type CHECK constraint, adds is_singleton column
|
||||||
|
-- for dynamic uniqueness enforcement, and creates appropriate indexes.
|
||||||
|
|
||||||
|
-- 1. Drop the old CHECK constraints on prompt_type (allows any string value now)
|
||||||
|
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS ai_prompts_prompt_type_check;
|
||||||
|
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS company_required_for_specific;
|
||||||
|
|
||||||
|
-- 2. Add is_singleton column (defaults to true for backwards compatibility)
|
||||||
|
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS is_singleton BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- 3. Drop ALL old unique constraints and indexes (cleanup)
|
||||||
|
-- Some were created as CONSTRAINTS (via ADD CONSTRAINT), others as standalone indexes
|
||||||
|
-- Must drop constraints first, then remaining standalone indexes
|
||||||
|
|
||||||
|
-- Drop constraints (these also remove their backing indexes)
|
||||||
|
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS unique_company_prompt;
|
||||||
|
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS idx_unique_general_prompt;
|
||||||
|
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS idx_unique_system_prompt;
|
||||||
|
|
||||||
|
-- Drop standalone indexes (IF EXISTS handles cases where they don't exist)
|
||||||
|
DROP INDEX IF EXISTS idx_unique_general_prompt;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_system_prompt;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_name_validation_system;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_name_validation_general;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_description_validation_system;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_description_validation_general;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_sanity_check_system;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_sanity_check_general;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_bulk_validation_system;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_bulk_validation_general;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_name_validation_company;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_description_validation_company;
|
||||||
|
DROP INDEX IF EXISTS idx_unique_bulk_validation_company;
|
||||||
|
|
||||||
|
-- 4. Create new partial unique indexes based on is_singleton
|
||||||
|
-- For singleton types WITHOUT company (only one per prompt_type)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_singleton_no_company
|
||||||
|
ON ai_prompts (prompt_type)
|
||||||
|
WHERE is_singleton = true AND company IS NULL;
|
||||||
|
|
||||||
|
-- For singleton types WITH company (only one per prompt_type + company combination)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_singleton_with_company
|
||||||
|
ON ai_prompts (prompt_type, company)
|
||||||
|
WHERE is_singleton = true AND company IS NOT NULL;
|
||||||
|
|
||||||
|
-- 5. Add index for fast lookups by type
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompt_type ON ai_prompts (prompt_type);
|
||||||
|
|
||||||
|
-- NOTE: After running this migration, you should:
|
||||||
|
-- 1. Delete existing prompts with old types (general, system, company_specific)
|
||||||
|
-- 2. Create new prompts with the new type naming convention:
|
||||||
|
-- - name_validation_system, name_validation_general, name_validation_company_specific
|
||||||
|
-- - description_validation_system, description_validation_general, description_validation_company_specific
|
||||||
|
-- - sanity_check_system, sanity_check_general
|
||||||
|
-- - bulk_validation_system, bulk_validation_general, bulk_validation_company_specific
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration: Create import_audit_log table
|
||||||
|
-- Permanent audit trail of all product import submissions sent to the API
|
||||||
|
-- Run this against your PostgreSQL database
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS import_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Who initiated the import
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
|
||||||
|
-- What was submitted
|
||||||
|
product_count INTEGER NOT NULL,
|
||||||
|
request_payload JSONB NOT NULL, -- The exact JSON array of products sent to the API
|
||||||
|
environment VARCHAR(10) NOT NULL, -- 'dev' or 'prod'
|
||||||
|
target_endpoint VARCHAR(255), -- The API URL that was called
|
||||||
|
use_test_data_source BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- What came back
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
response_payload JSONB, -- Full API response
|
||||||
|
error_message TEXT, -- Extracted error message on failure
|
||||||
|
created_count INTEGER DEFAULT 0, -- Number of products successfully created
|
||||||
|
errored_count INTEGER DEFAULT 0, -- Number of products that errored
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
session_id INTEGER, -- Optional link to the import_session used (if any)
|
||||||
|
duration_ms INTEGER, -- How long the API call took
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for looking up logs by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_id
|
||||||
|
ON import_audit_log (user_id);
|
||||||
|
|
||||||
|
-- Index for filtering by success/failure
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_success
|
||||||
|
ON import_audit_log (success);
|
||||||
|
|
||||||
|
-- Index for time-based queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_created_at
|
||||||
|
ON import_audit_log (created_at DESC);
|
||||||
|
|
||||||
|
-- Composite index for user + time queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_created
|
||||||
|
ON import_audit_log (user_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE import_audit_log IS 'Permanent audit log of all product import API submissions';
|
||||||
|
COMMENT ON COLUMN import_audit_log.request_payload IS 'Exact JSON products array sent to the external API';
|
||||||
|
COMMENT ON COLUMN import_audit_log.response_payload IS 'Full response received from the external API';
|
||||||
|
COMMENT ON COLUMN import_audit_log.environment IS 'dev or prod - which API endpoint was targeted';
|
||||||
|
COMMENT ON COLUMN import_audit_log.session_id IS 'Optional reference to import_sessions.id if session was active';
|
||||||
|
COMMENT ON COLUMN import_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds';
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Migration: Create product_editor_audit_log table
|
||||||
|
-- Permanent audit trail of all product editor API submissions
|
||||||
|
-- Run this against your PostgreSQL database
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS product_editor_audit_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Who made the edit
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username VARCHAR(255),
|
||||||
|
|
||||||
|
-- Which product
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- What was submitted
|
||||||
|
action VARCHAR(50) NOT NULL, -- 'product_edit', 'image_changes', 'taxonomy_set'
|
||||||
|
request_payload JSONB NOT NULL, -- The exact payload sent to the external API
|
||||||
|
target_endpoint VARCHAR(255), -- The API URL that was called
|
||||||
|
|
||||||
|
-- What came back
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
response_payload JSONB, -- Full API response
|
||||||
|
error_message TEXT, -- Extracted error message on failure
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
duration_ms INTEGER, -- How long the API call took
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for looking up edits by product
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid
|
||||||
|
ON product_editor_audit_log (pid);
|
||||||
|
|
||||||
|
-- Index for looking up edits by user
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_id
|
||||||
|
ON product_editor_audit_log (user_id);
|
||||||
|
|
||||||
|
-- Index for time-based queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_created_at
|
||||||
|
ON product_editor_audit_log (created_at DESC);
|
||||||
|
|
||||||
|
-- Composite index for product + time queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid_created
|
||||||
|
ON product_editor_audit_log (pid, created_at DESC);
|
||||||
|
|
||||||
|
-- Composite index for user + time queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_created
|
||||||
|
ON product_editor_audit_log (user_id, created_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE product_editor_audit_log IS 'Permanent audit log of all product editor API submissions';
|
||||||
|
COMMENT ON COLUMN product_editor_audit_log.action IS 'Type of edit: product_edit, image_changes, or taxonomy_set';
|
||||||
|
COMMENT ON COLUMN product_editor_audit_log.request_payload IS 'Exact payload sent to the external API';
|
||||||
|
COMMENT ON COLUMN product_editor_audit_log.response_payload IS 'Full response received from the external API';
|
||||||
|
COMMENT ON COLUMN product_editor_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Phase 6.2: per-route permission codes
|
||||||
|
-- Seeds the permission codes referenced by Phase 6 hardening middleware.
|
||||||
|
-- Safe to run multiple times (ON CONFLICT DO NOTHING).
|
||||||
|
--
|
||||||
|
-- Codes follow the plan's spec (CONSOLIDATION_PLAN.md §6.2):
|
||||||
|
-- product_import — POST/PUT/DELETE on /api/import
|
||||||
|
-- data_management — POST/PUT/DELETE on /api/csv (data-management.js)
|
||||||
|
-- ai_admin — POST/PUT/DELETE on /api/ai-prompts, /api/ai-validation
|
||||||
|
-- templates_write — POST/PUT/DELETE on /api/templates
|
||||||
|
-- image_admin — POST/DELETE on /api/reusable-images
|
||||||
|
-- audit_read — reserved for future read-gating on audit logs
|
||||||
|
-- acot_admin — reserved for acot-server (Phase 5 scope)
|
||||||
|
-- klaviyo_* / meta_* / google_* / typeform_* — reserved for dashboard-server (Phase 4 scope)
|
||||||
|
--
|
||||||
|
-- Admin users (is_admin = true) automatically pass any requirePermission() check,
|
||||||
|
-- so this migration does not auto-grant codes to admins. New non-admin users get
|
||||||
|
-- write access only when explicitly granted via the user-management UI.
|
||||||
|
|
||||||
|
INSERT INTO permissions (code, name, category, description) VALUES
|
||||||
|
('product_import', 'Product Import (write)', 'Imports',
|
||||||
|
'Allows POST/PUT/DELETE on /api/import — uploads, deletes, generate-upc, etc.'),
|
||||||
|
('data_management', 'Data Management (write)', 'Data',
|
||||||
|
'Allows POST/PUT/DELETE on /api/csv — CSV operations, full updates, full resets.'),
|
||||||
|
('ai_admin', 'AI Settings Admin', 'AI',
|
||||||
|
'Allows write access to AI prompts and AI validation endpoints.'),
|
||||||
|
('templates_write', 'Template Editing', 'Templates',
|
||||||
|
'Allows POST/PUT/DELETE on /api/templates.'),
|
||||||
|
('image_admin', 'Image Management', 'Images',
|
||||||
|
'Allows uploads and deletions on /api/reusable-images.'),
|
||||||
|
('audit_read', 'Audit Log Access', 'Audit',
|
||||||
|
'Reserved for future read-gating of import + product-editor audit logs.'),
|
||||||
|
('klaviyo_write', 'Klaviyo Write', 'Dashboard',
|
||||||
|
'Reserved for dashboard-server: mutates Klaviyo lists/segments.'),
|
||||||
|
('klaviyo_admin', 'Klaviyo Admin', 'Dashboard',
|
||||||
|
'Reserved for dashboard-server: triggers campaign syncs.'),
|
||||||
|
('meta_write', 'Meta Write', 'Dashboard',
|
||||||
|
'Reserved for dashboard-server: Meta API write operations.'),
|
||||||
|
('google_write', 'Google Analytics Write', 'Dashboard',
|
||||||
|
'Reserved for dashboard-server: GA write operations.'),
|
||||||
|
('typeform_write', 'Typeform Write', 'Dashboard',
|
||||||
|
'Reserved for dashboard-server: Typeform write operations.'),
|
||||||
|
('acot_admin', 'ACOT Server Admin', 'ACOT',
|
||||||
|
'Reserved for acot-server admin endpoints.')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- Phase 2 deviation #6 cleanup: drop defunct frontend permissions if present.
|
||||||
|
-- These corresponded to the removed Aircall/Gorgias dashboards.
|
||||||
|
DELETE FROM user_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE code IN ('dashboard:gorgias', 'dashboard:calls')
|
||||||
|
);
|
||||||
|
DELETE FROM permissions WHERE code IN ('dashboard:gorgias', 'dashboard:calls');
|
||||||
Executable → Regular
+839
-138
File diff suppressed because it is too large
Load Diff
Executable → Regular
+8
-1
@@ -2,6 +2,7 @@
|
|||||||
"name": "inventory-server",
|
"name": "inventory-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend server for inventory management system",
|
"description": "Backend server for inventory management system",
|
||||||
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
@@ -27,11 +28,17 @@
|
|||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"openai": "^4.85.3",
|
"openai": "^6.0.0",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-http": "^10.3.0",
|
||||||
"pm2": "^5.3.0",
|
"pm2": "^5.3.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"ssh2": "^1.16.0",
|
"ssh2": "^1.16.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const RUN_PERIODIC_METRICS = true;
|
|||||||
const RUN_BRAND_METRICS = true;
|
const RUN_BRAND_METRICS = true;
|
||||||
const RUN_VENDOR_METRICS = true;
|
const RUN_VENDOR_METRICS = true;
|
||||||
const RUN_CATEGORY_METRICS = true;
|
const RUN_CATEGORY_METRICS = true;
|
||||||
|
const RUN_LIFECYCLE_FORECASTS = true;
|
||||||
|
|
||||||
// Maximum execution time for the entire sequence (e.g., 90 minutes)
|
// Maximum execution time for the entire sequence (e.g., 90 minutes)
|
||||||
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
|
const MAX_EXECUTION_TIME_TOTAL = 90 * 60 * 1000;
|
||||||
@@ -31,7 +32,7 @@ const envPaths = [
|
|||||||
path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env)
|
path.resolve(__dirname, '../..', '.env'), // Two levels up (inventory/.env)
|
||||||
path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env)
|
path.resolve(__dirname, '..', '.env'), // One level up (inventory-server/.env)
|
||||||
path.resolve(__dirname, '.env'), // Same directory
|
path.resolve(__dirname, '.env'), // Same directory
|
||||||
'/var/www/html/inventory/.env' // Server absolute path
|
'/var/www/inventory/.env' // Server absolute path
|
||||||
];
|
];
|
||||||
|
|
||||||
let envLoaded = false;
|
let envLoaded = false;
|
||||||
@@ -592,6 +593,13 @@ async function runAllCalculations() {
|
|||||||
historyType: 'product_metrics',
|
historyType: 'product_metrics',
|
||||||
statusModule: 'product_metrics'
|
statusModule: 'product_metrics'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
run: RUN_LIFECYCLE_FORECASTS,
|
||||||
|
name: 'Lifecycle Forecast Update',
|
||||||
|
sqlFile: 'metrics-new/update_lifecycle_forecasts.sql',
|
||||||
|
historyType: 'lifecycle_forecasts',
|
||||||
|
statusModule: 'lifecycle_forecasts'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
run: RUN_PERIODIC_METRICS,
|
run: RUN_PERIODIC_METRICS,
|
||||||
name: 'Periodic Metrics Update',
|
name: 'Periodic Metrics Update',
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Embedding Proof-of-Concept Script
|
||||||
|
*
|
||||||
|
* Demonstrates how category embeddings work for product matching.
|
||||||
|
* Uses OpenAI text-embedding-3-small model.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/embedding-poc.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
const { getDbConnection, closeAllConnections } = require('../src/utils/dbConnection');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||||
|
const EMBEDDING_MODEL = 'text-embedding-3-small';
|
||||||
|
const EMBEDDING_DIMENSIONS = 1536;
|
||||||
|
|
||||||
|
// Sample products to test (you can modify these)
|
||||||
|
const TEST_PRODUCTS = [
|
||||||
|
{
|
||||||
|
name: "Cosmos Infinity Chipboard - Stamperia",
|
||||||
|
description: "Laser-cut chipboard shapes featuring celestial designs for mixed media projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Distress Oxide Ink Pad - Mermaid Lagoon",
|
||||||
|
description: "Water-reactive dye ink that creates an oxidized effect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hedwig Puffy Stickers - Paper House Productions",
|
||||||
|
description: "3D puffy stickers featuring Harry Potter's owl Hedwig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Black Velvet Watercolor Brush Size 6",
|
||||||
|
description: "Round brush for watercolor painting with synthetic bristles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Floral Washi Tape Set",
|
||||||
|
description: "Decorative paper tape with flower patterns, pack of 6 rolls"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenAI Embedding Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function getEmbeddings(texts) {
|
||||||
|
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: texts.map(t => t.substring(0, 8000)), // Max 8k chars per text
|
||||||
|
model: EMBEDDING_MODEL,
|
||||||
|
dimensions: EMBEDDING_DIMENSIONS
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(`OpenAI API error: ${error.error?.message || response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Sort by index to ensure order matches input
|
||||||
|
const sorted = data.data.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
embeddings: sorted.map(item => item.embedding),
|
||||||
|
usage: data.usage,
|
||||||
|
model: data.model
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Vector Math
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function cosineSimilarity(a, b) {
|
||||||
|
let dotProduct = 0;
|
||||||
|
let normA = 0;
|
||||||
|
let normB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dotProduct += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTopMatches(queryEmbedding, categoryEmbeddings, topK = 10) {
|
||||||
|
const scored = categoryEmbeddings.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
similarity: cosineSimilarity(queryEmbedding, cat.embedding)
|
||||||
|
}));
|
||||||
|
|
||||||
|
scored.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
|
||||||
|
return scored.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Database Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function fetchCategories(connection) {
|
||||||
|
console.log('\n📂 Fetching categories from database...');
|
||||||
|
|
||||||
|
// Fetch hierarchical categories (types 10-13)
|
||||||
|
const [rows] = await connection.query(`
|
||||||
|
SELECT
|
||||||
|
cat_id,
|
||||||
|
name,
|
||||||
|
master_cat_id,
|
||||||
|
type
|
||||||
|
FROM product_categories
|
||||||
|
WHERE type IN (10, 11, 12, 13)
|
||||||
|
ORDER BY type, name
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(` Found ${rows.length} category records`);
|
||||||
|
|
||||||
|
// Build category paths
|
||||||
|
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||||
|
const categories = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const path = [];
|
||||||
|
let current = row;
|
||||||
|
|
||||||
|
// Walk up the tree to build full path
|
||||||
|
while (current) {
|
||||||
|
path.unshift(current.name);
|
||||||
|
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
categories.push({
|
||||||
|
id: row.cat_id,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
fullPath: path.join(' > '),
|
||||||
|
embeddingText: path.join(' ') // For embedding generation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by level
|
||||||
|
const levels = {
|
||||||
|
10: categories.filter(c => c.type === 10).length,
|
||||||
|
11: categories.filter(c => c.type === 11).length,
|
||||||
|
12: categories.filter(c => c.type === 12).length,
|
||||||
|
13: categories.filter(c => c.type === 13).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(` Level breakdown: ${levels[10]} top-level, ${levels[11]} L2, ${levels[12]} L3, ${levels[13]} L4`);
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Script
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('═══════════════════════════════════════════════════════════════');
|
||||||
|
console.log(' EMBEDDING PROOF-OF-CONCEPT');
|
||||||
|
console.log(' Model: ' + EMBEDDING_MODEL);
|
||||||
|
console.log('═══════════════════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
if (!OPENAI_API_KEY) {
|
||||||
|
console.error('❌ OPENAI_API_KEY not found in environment');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Connect to database
|
||||||
|
console.log('\n🔌 Connecting to database via SSH tunnel...');
|
||||||
|
const { connection: conn } = await getDbConnection();
|
||||||
|
connection = conn;
|
||||||
|
console.log(' ✅ Connected');
|
||||||
|
|
||||||
|
// Step 2: Fetch categories
|
||||||
|
const categories = await fetchCategories(connection);
|
||||||
|
|
||||||
|
// Step 3: Generate embeddings for categories
|
||||||
|
console.log('\n🧮 Generating embeddings for categories...');
|
||||||
|
console.log(' This will cost approximately $' + (categories.length * 0.00002).toFixed(4));
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Process in batches of 100 (OpenAI limit is 2048)
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
let totalTokens = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < categories.length; i += BATCH_SIZE) {
|
||||||
|
const batch = categories.slice(i, i + BATCH_SIZE);
|
||||||
|
const texts = batch.map(c => c.embeddingText);
|
||||||
|
|
||||||
|
const result = await getEmbeddings(texts);
|
||||||
|
|
||||||
|
// Attach embeddings to categories
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
batch[j].embedding = result.embeddings[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTokens += result.usage.total_tokens;
|
||||||
|
console.log(` Batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(categories.length / BATCH_SIZE)}: ${batch.length} categories embedded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeddingTime = Date.now() - startTime;
|
||||||
|
console.log(` ✅ Generated ${categories.length} embeddings in ${embeddingTime}ms`);
|
||||||
|
console.log(` 📊 Total tokens used: ${totalTokens} (~$${(totalTokens * 0.00002).toFixed(4)})`);
|
||||||
|
|
||||||
|
// Step 4: Test with sample products
|
||||||
|
console.log('\n═══════════════════════════════════════════════════════════════');
|
||||||
|
console.log(' TESTING WITH SAMPLE PRODUCTS');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
for (const product of TEST_PRODUCTS) {
|
||||||
|
console.log('\n┌─────────────────────────────────────────────────────────────');
|
||||||
|
console.log(`│ Product: "${product.name}"`);
|
||||||
|
console.log(`│ Description: "${product.description.substring(0, 60)}..."`);
|
||||||
|
console.log('├─────────────────────────────────────────────────────────────');
|
||||||
|
|
||||||
|
// Generate embedding for product
|
||||||
|
const productText = `${product.name} ${product.description}`;
|
||||||
|
const { embeddings: [productEmbedding] } = await getEmbeddings([productText]);
|
||||||
|
|
||||||
|
// Find top matches
|
||||||
|
const matches = findTopMatches(productEmbedding, categories, 10);
|
||||||
|
|
||||||
|
console.log('│ Top 10 Category Matches:');
|
||||||
|
matches.forEach((match, i) => {
|
||||||
|
const similarity = (match.similarity * 100).toFixed(1);
|
||||||
|
const bar = '█'.repeat(Math.round(match.similarity * 20));
|
||||||
|
const marker = i < 3 ? ' ✅' : '';
|
||||||
|
console.log(`│ ${(i + 1).toString().padStart(2)}. [${similarity.padStart(5)}%] ${bar.padEnd(20)} ${match.fullPath}${marker}`);
|
||||||
|
});
|
||||||
|
console.log('└─────────────────────────────────────────────────────────────');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Summary
|
||||||
|
console.log('\n═══════════════════════════════════════════════════════════════');
|
||||||
|
console.log(' SUMMARY');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════════');
|
||||||
|
console.log(` Categories embedded: ${categories.length}`);
|
||||||
|
console.log(` Embedding time: ${embeddingTime}ms (one-time cost)`);
|
||||||
|
console.log(` Per-product lookup: ~${(Date.now() - startTime) / TEST_PRODUCTS.length}ms`);
|
||||||
|
console.log(` Vector dimensions: ${EMBEDDING_DIMENSIONS}`);
|
||||||
|
console.log(` Memory usage: ~${(categories.length * EMBEDDING_DIMENSIONS * 4 / 1024 / 1024).toFixed(2)} MB (in-memory vectors)`);
|
||||||
|
console.log('');
|
||||||
|
console.log(' 💡 In production:');
|
||||||
|
console.log(' - Category embeddings are computed once and cached');
|
||||||
|
console.log(' - Only product embedding is computed per-request (~$0.00002)');
|
||||||
|
console.log(' - Vector search is instant (in-memory cosine similarity)');
|
||||||
|
console.log(' - Top 10 results go to AI for final selection (~$0.0001)');
|
||||||
|
console.log('═══════════════════════════════════════════════════════════════\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Error:', error.message);
|
||||||
|
if (error.stack) {
|
||||||
|
console.error(error.stack);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await closeAllConnections();
|
||||||
|
console.log('🔌 Database connections closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
main();
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
scipy>=1.10
|
||||||
|
pandas>=2.0
|
||||||
|
psycopg2-binary>=2.9
|
||||||
|
statsmodels>=0.14
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Forecast Pipeline Orchestrator
|
||||||
|
*
|
||||||
|
* Spawns the Python forecast engine with database credentials from the
|
||||||
|
* environment. Can be run manually, via cron, or integrated into the
|
||||||
|
* existing metrics pipeline.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node run_forecast.js
|
||||||
|
*
|
||||||
|
* Environment:
|
||||||
|
* Reads DB_HOST, DB_USER, DB_PASSWORD, DB_NAME, DB_PORT from
|
||||||
|
* /var/www/inventory/.env (or current process env).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Load .env file if it exists (production path)
|
||||||
|
const envPaths = [
|
||||||
|
'/var/www/inventory/.env',
|
||||||
|
path.join(__dirname, '../../.env'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const envPath of envPaths) {
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf-8');
|
||||||
|
for (const line of envContent.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIndex = trimmed.indexOf('=');
|
||||||
|
if (eqIndex === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIndex);
|
||||||
|
const value = trimmed.slice(eqIndex + 1);
|
||||||
|
if (!process.env[key]) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`Loaded env from ${envPath}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify required env vars
|
||||||
|
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
|
||||||
|
const missing = required.filter(k => !process.env[k]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRIPT_DIR = __dirname;
|
||||||
|
const PYTHON_SCRIPT = path.join(SCRIPT_DIR, 'forecast_engine.py');
|
||||||
|
const VENV_DIR = path.join(SCRIPT_DIR, 'venv');
|
||||||
|
const REQUIREMENTS = path.join(SCRIPT_DIR, 'requirements.txt');
|
||||||
|
|
||||||
|
// Determine python binary (prefer venv if it exists)
|
||||||
|
function getPythonBin() {
|
||||||
|
const venvPython = path.join(VENV_DIR, 'bin', 'python');
|
||||||
|
if (fs.existsSync(venvPython)) return venvPython;
|
||||||
|
|
||||||
|
// Fall back to system python
|
||||||
|
return 'python3';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure venv and dependencies are installed
|
||||||
|
async function ensureDependencies() {
|
||||||
|
if (!fs.existsSync(path.join(VENV_DIR, 'bin', 'python'))) {
|
||||||
|
console.log('Creating virtual environment...');
|
||||||
|
await runCommand('python3', ['-m', 'venv', VENV_DIR]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always run pip install — idempotent, fast when packages already present
|
||||||
|
console.log('Checking dependencies...');
|
||||||
|
const python = path.join(VENV_DIR, 'bin', 'python');
|
||||||
|
await runCommand(python, ['-m', 'pip', 'install', '--quiet', '-r', REQUIREMENTS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(cmd, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(cmd, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
proc.on('close', code => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`${cmd} exited with code ${code}`));
|
||||||
|
});
|
||||||
|
proc.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const startTime = Date.now();
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`Forecast Pipeline - ${new Date().toISOString()}`);
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureDependencies();
|
||||||
|
|
||||||
|
const pythonBin = getPythonBin();
|
||||||
|
console.log(`Using Python: ${pythonBin}`);
|
||||||
|
console.log(`Running: ${PYTHON_SCRIPT}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
await runCommand(pythonBin, [PYTHON_SCRIPT], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PYTHONUNBUFFERED: '1', // Real-time output
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.log('');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`Forecast pipeline completed in ${duration}s`);
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
} catch (err) {
|
||||||
|
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
console.error(`Forecast pipeline FAILED after ${duration}s:`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Forecasting Pipeline Tables
|
||||||
|
-- Run once to create the schema. Safe to re-run (IF NOT EXISTS).
|
||||||
|
|
||||||
|
-- Precomputed reference decay curves per brand (or brand x category at any hierarchy level)
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_lifecycle_curves (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
brand TEXT NOT NULL,
|
||||||
|
root_category TEXT, -- NULL = brand-level fallback curve, else category name
|
||||||
|
cat_id BIGINT, -- NULL = brand-only; else category_hierarchy.cat_id for precise matching
|
||||||
|
category_level SMALLINT, -- NULL = brand-only; 0-3 = hierarchy depth
|
||||||
|
amplitude NUMERIC(10,4), -- A in: sales(t) = A * exp(-λt) + C
|
||||||
|
decay_rate NUMERIC(10,6), -- λ (higher = faster decay)
|
||||||
|
baseline NUMERIC(10,4), -- C (long-tail steady-state daily sales)
|
||||||
|
r_squared NUMERIC(6,4), -- goodness of fit
|
||||||
|
sample_size INT, -- number of products that informed this curve
|
||||||
|
median_first_week_sales NUMERIC(10,2), -- for scaling new launches
|
||||||
|
median_preorder_sales NUMERIC(10,2), -- for scaling pre-order products
|
||||||
|
median_preorder_days NUMERIC(10,2), -- median pre-order accumulation window (days)
|
||||||
|
computed_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(brand, cat_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-product daily forecasts (next 90 days, regenerated each run)
|
||||||
|
CREATE TABLE IF NOT EXISTS product_forecasts (
|
||||||
|
pid BIGINT NOT NULL,
|
||||||
|
forecast_date DATE NOT NULL,
|
||||||
|
forecast_units NUMERIC(10,2),
|
||||||
|
forecast_revenue NUMERIC(14,4),
|
||||||
|
lifecycle_phase TEXT, -- preorder, launch, decay, mature, slow_mover, dormant
|
||||||
|
forecast_method TEXT, -- lifecycle_curve, exp_smoothing, velocity, zero
|
||||||
|
confidence_lower NUMERIC(10,2),
|
||||||
|
confidence_upper NUMERIC(10,2),
|
||||||
|
generated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (pid, forecast_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pf_date ON product_forecasts(forecast_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pf_phase ON product_forecasts(lifecycle_phase);
|
||||||
|
|
||||||
|
-- Forecast run history (for monitoring)
|
||||||
|
CREATE TABLE IF NOT EXISTS forecast_runs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
started_at TIMESTAMP NOT NULL,
|
||||||
|
finished_at TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'running', -- running, completed, failed
|
||||||
|
products_forecast INT,
|
||||||
|
phase_counts JSONB, -- {"launch": 50, "decay": 200, ...}
|
||||||
|
curve_count INT, -- brand curves computed
|
||||||
|
error_message TEXT,
|
||||||
|
duration_seconds NUMERIC(10,2)
|
||||||
|
);
|
||||||
@@ -6,6 +6,8 @@ const importCategories = require('./import/categories');
|
|||||||
const { importProducts } = require('./import/products');
|
const { importProducts } = require('./import/products');
|
||||||
const importOrders = require('./import/orders');
|
const importOrders = require('./import/orders');
|
||||||
const importPurchaseOrders = require('./import/purchase-orders');
|
const importPurchaseOrders = require('./import/purchase-orders');
|
||||||
|
const importDailyDeals = require('./import/daily-deals');
|
||||||
|
const importStockSnapshots = require('./import/stock-snapshots');
|
||||||
|
|
||||||
dotenv.config({ path: path.join(__dirname, "../.env") });
|
dotenv.config({ path: path.join(__dirname, "../.env") });
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ const IMPORT_CATEGORIES = true;
|
|||||||
const IMPORT_PRODUCTS = true;
|
const IMPORT_PRODUCTS = true;
|
||||||
const IMPORT_ORDERS = true;
|
const IMPORT_ORDERS = true;
|
||||||
const IMPORT_PURCHASE_ORDERS = true;
|
const IMPORT_PURCHASE_ORDERS = true;
|
||||||
|
const IMPORT_DAILY_DEALS = true;
|
||||||
|
const IMPORT_STOCK_SNAPSHOTS = true;
|
||||||
|
|
||||||
// Add flag for incremental updates
|
// Add flag for incremental updates
|
||||||
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
|
||||||
@@ -36,7 +40,7 @@ const sshConfig = {
|
|||||||
password: process.env.PROD_DB_PASSWORD,
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
database: process.env.PROD_DB_NAME,
|
database: process.env.PROD_DB_NAME,
|
||||||
port: process.env.PROD_DB_PORT || 3306,
|
port: process.env.PROD_DB_PORT || 3306,
|
||||||
timezone: '-05:00', // Production DB always stores times in EST (UTC-5) regardless of DST
|
timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
|
||||||
},
|
},
|
||||||
localDbConfig: {
|
localDbConfig: {
|
||||||
// PostgreSQL config for local
|
// PostgreSQL config for local
|
||||||
@@ -78,7 +82,9 @@ async function main() {
|
|||||||
IMPORT_CATEGORIES,
|
IMPORT_CATEGORIES,
|
||||||
IMPORT_PRODUCTS,
|
IMPORT_PRODUCTS,
|
||||||
IMPORT_ORDERS,
|
IMPORT_ORDERS,
|
||||||
IMPORT_PURCHASE_ORDERS
|
IMPORT_PURCHASE_ORDERS,
|
||||||
|
IMPORT_DAILY_DEALS,
|
||||||
|
IMPORT_STOCK_SNAPSHOTS
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -126,10 +132,12 @@ async function main() {
|
|||||||
'categories_enabled', $2::boolean,
|
'categories_enabled', $2::boolean,
|
||||||
'products_enabled', $3::boolean,
|
'products_enabled', $3::boolean,
|
||||||
'orders_enabled', $4::boolean,
|
'orders_enabled', $4::boolean,
|
||||||
'purchase_orders_enabled', $5::boolean
|
'purchase_orders_enabled', $5::boolean,
|
||||||
|
'daily_deals_enabled', $6::boolean,
|
||||||
|
'stock_snapshots_enabled', $7::boolean
|
||||||
)
|
)
|
||||||
) RETURNING id
|
) RETURNING id
|
||||||
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS]);
|
`, [INCREMENTAL_UPDATE, IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, IMPORT_PURCHASE_ORDERS, IMPORT_DAILY_DEALS, IMPORT_STOCK_SNAPSHOTS]);
|
||||||
importHistoryId = historyResult.rows[0].id;
|
importHistoryId = historyResult.rows[0].id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating import history record:", error);
|
console.error("Error creating import history record:", error);
|
||||||
@@ -146,7 +154,9 @@ async function main() {
|
|||||||
categories: null,
|
categories: null,
|
||||||
products: null,
|
products: null,
|
||||||
orders: null,
|
orders: null,
|
||||||
purchaseOrders: null
|
purchaseOrders: null,
|
||||||
|
dailyDeals: null,
|
||||||
|
stockSnapshots: null
|
||||||
};
|
};
|
||||||
|
|
||||||
let totalRecordsAdded = 0;
|
let totalRecordsAdded = 0;
|
||||||
@@ -224,6 +234,61 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IMPORT_DAILY_DEALS) {
|
||||||
|
try {
|
||||||
|
const stepStart = Date.now();
|
||||||
|
results.dailyDeals = await importDailyDeals(prodConnection, localConnection);
|
||||||
|
stepTimings.dailyDeals = Math.round((Date.now() - stepStart) / 1000);
|
||||||
|
|
||||||
|
if (isImportCancelled) throw new Error("Import cancelled");
|
||||||
|
completedSteps++;
|
||||||
|
console.log('Daily deals import result:', results.dailyDeals);
|
||||||
|
|
||||||
|
if (results.dailyDeals?.status === 'error') {
|
||||||
|
console.error('Daily deals import had an error:', results.dailyDeals.error);
|
||||||
|
} else {
|
||||||
|
totalRecordsAdded += parseInt(results.dailyDeals?.recordsAdded || 0);
|
||||||
|
totalRecordsUpdated += parseInt(results.dailyDeals?.recordsUpdated || 0);
|
||||||
|
totalRecordsDeleted += parseInt(results.dailyDeals?.recordsDeleted || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during daily deals import:', error);
|
||||||
|
results.dailyDeals = {
|
||||||
|
status: 'error',
|
||||||
|
error: error.message,
|
||||||
|
recordsAdded: 0,
|
||||||
|
recordsUpdated: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMPORT_STOCK_SNAPSHOTS) {
|
||||||
|
try {
|
||||||
|
const stepStart = Date.now();
|
||||||
|
results.stockSnapshots = await importStockSnapshots(prodConnection, localConnection, INCREMENTAL_UPDATE);
|
||||||
|
stepTimings.stockSnapshots = Math.round((Date.now() - stepStart) / 1000);
|
||||||
|
|
||||||
|
if (isImportCancelled) throw new Error("Import cancelled");
|
||||||
|
completedSteps++;
|
||||||
|
console.log('Stock snapshots import result:', results.stockSnapshots);
|
||||||
|
|
||||||
|
if (results.stockSnapshots?.status === 'error') {
|
||||||
|
console.error('Stock snapshots import had an error:', results.stockSnapshots.error);
|
||||||
|
} else {
|
||||||
|
totalRecordsAdded += parseInt(results.stockSnapshots?.recordsAdded || 0);
|
||||||
|
totalRecordsUpdated += parseInt(results.stockSnapshots?.recordsUpdated || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during stock snapshots import:', error);
|
||||||
|
results.stockSnapshots = {
|
||||||
|
status: 'error',
|
||||||
|
error: error.message,
|
||||||
|
recordsAdded: 0,
|
||||||
|
recordsUpdated: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const endTime = Date.now();
|
const endTime = Date.now();
|
||||||
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
const totalElapsedSeconds = Math.round((endTime - startTime) / 1000);
|
||||||
|
|
||||||
@@ -241,15 +306,19 @@ async function main() {
|
|||||||
'products_enabled', $5::boolean,
|
'products_enabled', $5::boolean,
|
||||||
'orders_enabled', $6::boolean,
|
'orders_enabled', $6::boolean,
|
||||||
'purchase_orders_enabled', $7::boolean,
|
'purchase_orders_enabled', $7::boolean,
|
||||||
'categories_result', COALESCE($8::jsonb, 'null'::jsonb),
|
'daily_deals_enabled', $8::boolean,
|
||||||
'products_result', COALESCE($9::jsonb, 'null'::jsonb),
|
'categories_result', COALESCE($9::jsonb, 'null'::jsonb),
|
||||||
'orders_result', COALESCE($10::jsonb, 'null'::jsonb),
|
'products_result', COALESCE($10::jsonb, 'null'::jsonb),
|
||||||
'purchase_orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
'orders_result', COALESCE($11::jsonb, 'null'::jsonb),
|
||||||
'total_deleted', $12::integer,
|
'purchase_orders_result', COALESCE($12::jsonb, 'null'::jsonb),
|
||||||
'total_skipped', $13::integer,
|
'daily_deals_result', COALESCE($13::jsonb, 'null'::jsonb),
|
||||||
'step_timings', $14::jsonb
|
'stock_snapshots_enabled', $14::boolean,
|
||||||
|
'stock_snapshots_result', COALESCE($15::jsonb, 'null'::jsonb),
|
||||||
|
'total_deleted', $16::integer,
|
||||||
|
'total_skipped', $17::integer,
|
||||||
|
'step_timings', $18::jsonb
|
||||||
)
|
)
|
||||||
WHERE id = $15
|
WHERE id = $19
|
||||||
`, [
|
`, [
|
||||||
totalElapsedSeconds,
|
totalElapsedSeconds,
|
||||||
parseInt(totalRecordsAdded),
|
parseInt(totalRecordsAdded),
|
||||||
@@ -258,10 +327,14 @@ async function main() {
|
|||||||
IMPORT_PRODUCTS,
|
IMPORT_PRODUCTS,
|
||||||
IMPORT_ORDERS,
|
IMPORT_ORDERS,
|
||||||
IMPORT_PURCHASE_ORDERS,
|
IMPORT_PURCHASE_ORDERS,
|
||||||
|
IMPORT_DAILY_DEALS,
|
||||||
JSON.stringify(results.categories),
|
JSON.stringify(results.categories),
|
||||||
JSON.stringify(results.products),
|
JSON.stringify(results.products),
|
||||||
JSON.stringify(results.orders),
|
JSON.stringify(results.orders),
|
||||||
JSON.stringify(results.purchaseOrders),
|
JSON.stringify(results.purchaseOrders),
|
||||||
|
JSON.stringify(results.dailyDeals),
|
||||||
|
IMPORT_STOCK_SNAPSHOTS,
|
||||||
|
JSON.stringify(results.stockSnapshots),
|
||||||
totalRecordsDeleted,
|
totalRecordsDeleted,
|
||||||
totalRecordsSkipped,
|
totalRecordsSkipped,
|
||||||
JSON.stringify(stepTimings),
|
JSON.stringify(stepTimings),
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
const { outputProgress, formatElapsedTime } = require('../metrics-new/utils/progress');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import daily deals from production MySQL to local PostgreSQL.
|
||||||
|
*
|
||||||
|
* Production has two tables:
|
||||||
|
* - product_daily_deals (deal_id, deal_date, pid, price_id)
|
||||||
|
* - product_current_prices (price_id, pid, price_each, active, ...)
|
||||||
|
*
|
||||||
|
* We join them in the prod query to denormalize the deal price, avoiding
|
||||||
|
* the need to sync the full product_current_prices table.
|
||||||
|
*
|
||||||
|
* On each sync:
|
||||||
|
* 1. Fetch deals from the last 7 days (plus today) from production
|
||||||
|
* 2. Upsert into local table
|
||||||
|
* 3. Hard delete local deals older than 7 days past their deal_date
|
||||||
|
*/
|
||||||
|
async function importDailyDeals(prodConnection, localConnection) {
|
||||||
|
outputProgress({
|
||||||
|
operation: "Starting daily deals import",
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localConnection.query('BEGIN');
|
||||||
|
|
||||||
|
// Fetch recent daily deals from production (MySQL 5.7, no CTEs)
|
||||||
|
// Join product_current_prices to get the actual deal price
|
||||||
|
// Only grab last 7 days + today + tomorrow (for pre-scheduled deals)
|
||||||
|
const [deals] = await prodConnection.query(`
|
||||||
|
SELECT
|
||||||
|
pdd.deal_id,
|
||||||
|
pdd.deal_date,
|
||||||
|
pdd.pid,
|
||||||
|
pdd.price_id,
|
||||||
|
pcp.price_each as deal_price
|
||||||
|
FROM product_daily_deals pdd
|
||||||
|
LEFT JOIN product_current_prices pcp ON pcp.price_id = pdd.price_id
|
||||||
|
WHERE pdd.deal_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||||
|
AND pdd.deal_date <= DATE_ADD(CURDATE(), INTERVAL 1 DAY)
|
||||||
|
ORDER BY pdd.deal_date DESC, pdd.pid
|
||||||
|
`);
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Daily deals import",
|
||||||
|
message: `Fetched ${deals.length} deals from production`,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalInserted = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
if (deals.length > 0) {
|
||||||
|
// Batch upsert — filter to only PIDs that exist locally
|
||||||
|
const pids = [...new Set(deals.map(d => d.pid))];
|
||||||
|
const existingResult = await localConnection.query(
|
||||||
|
`SELECT pid FROM products WHERE pid = ANY($1)`,
|
||||||
|
[pids]
|
||||||
|
);
|
||||||
|
const existingPids = new Set(
|
||||||
|
(Array.isArray(existingResult) ? existingResult[0] : existingResult)
|
||||||
|
.rows.map(r => Number(r.pid))
|
||||||
|
);
|
||||||
|
|
||||||
|
const validDeals = deals.filter(d => existingPids.has(Number(d.pid)));
|
||||||
|
|
||||||
|
if (validDeals.length > 0) {
|
||||||
|
// Build batch upsert
|
||||||
|
const values = validDeals.flatMap(d => [
|
||||||
|
d.deal_date,
|
||||||
|
d.pid,
|
||||||
|
d.price_id,
|
||||||
|
d.deal_price ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const placeholders = validDeals
|
||||||
|
.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`)
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
const upsertQuery = `
|
||||||
|
WITH upserted AS (
|
||||||
|
INSERT INTO product_daily_deals (deal_date, pid, price_id, deal_price)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (deal_date, pid) DO UPDATE SET
|
||||||
|
price_id = EXCLUDED.price_id,
|
||||||
|
deal_price = EXCLUDED.deal_price
|
||||||
|
WHERE
|
||||||
|
product_daily_deals.price_id IS DISTINCT FROM EXCLUDED.price_id OR
|
||||||
|
product_daily_deals.deal_price IS DISTINCT FROM EXCLUDED.deal_price
|
||||||
|
RETURNING
|
||||||
|
CASE WHEN xmax = 0 THEN true ELSE false END as is_insert
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE is_insert) as inserted,
|
||||||
|
COUNT(*) FILTER (WHERE NOT is_insert) as updated
|
||||||
|
FROM upserted
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await localConnection.query(upsertQuery, values);
|
||||||
|
const queryResult = Array.isArray(result) ? result[0] : result;
|
||||||
|
totalInserted = parseInt(queryResult.rows[0].inserted) || 0;
|
||||||
|
totalUpdated = parseInt(queryResult.rows[0].updated) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipped = deals.length - validDeals.length;
|
||||||
|
if (skipped > 0) {
|
||||||
|
console.log(`Skipped ${skipped} deals (PIDs not in local products table)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard delete deals older than 7 days past their deal_date
|
||||||
|
const deleteResult = await localConnection.query(`
|
||||||
|
DELETE FROM product_daily_deals
|
||||||
|
WHERE deal_date < CURRENT_DATE - INTERVAL '7 days'
|
||||||
|
`);
|
||||||
|
const deletedCount = deleteResult.rowCount ??
|
||||||
|
(Array.isArray(deleteResult) ? deleteResult[0]?.rowCount : 0) ?? 0;
|
||||||
|
|
||||||
|
// Update sync status
|
||||||
|
await localConnection.query(`
|
||||||
|
INSERT INTO sync_status (table_name, last_sync_timestamp)
|
||||||
|
VALUES ('product_daily_deals', NOW())
|
||||||
|
ON CONFLICT (table_name) DO UPDATE SET
|
||||||
|
last_sync_timestamp = NOW()
|
||||||
|
`);
|
||||||
|
|
||||||
|
await localConnection.query('COMMIT');
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: "complete",
|
||||||
|
operation: "Daily deals import completed",
|
||||||
|
message: `Inserted ${totalInserted}, updated ${totalUpdated}, deleted ${deletedCount} expired`,
|
||||||
|
current: totalInserted + totalUpdated,
|
||||||
|
total: totalInserted + totalUpdated,
|
||||||
|
duration: formatElapsedTime(startTime),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "complete",
|
||||||
|
recordsAdded: totalInserted,
|
||||||
|
recordsUpdated: totalUpdated,
|
||||||
|
recordsDeleted: deletedCount,
|
||||||
|
totalRecords: totalInserted + totalUpdated,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error importing daily deals:", error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localConnection.query('ROLLBACK');
|
||||||
|
} catch (rollbackError) {
|
||||||
|
console.error("Error during rollback:", rollbackError);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: "error",
|
||||||
|
operation: "Daily deals import failed",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = importDailyDeals;
|
||||||
@@ -17,6 +17,33 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const skippedOrders = new Set();
|
const skippedOrders = new Set();
|
||||||
const missingProducts = new Set();
|
const missingProducts = new Set();
|
||||||
|
|
||||||
|
// Map order status codes to text values (consistent with PO status mapping in purchase-orders.js)
|
||||||
|
const orderStatusMap = {
|
||||||
|
0: 'created',
|
||||||
|
10: 'unfinished',
|
||||||
|
15: 'canceled',
|
||||||
|
16: 'combined',
|
||||||
|
20: 'placed',
|
||||||
|
22: 'placed_incomplete',
|
||||||
|
30: 'canceled',
|
||||||
|
40: 'awaiting_payment',
|
||||||
|
50: 'awaiting_products',
|
||||||
|
55: 'shipping_later',
|
||||||
|
56: 'shipping_together',
|
||||||
|
60: 'ready',
|
||||||
|
61: 'flagged',
|
||||||
|
62: 'fix_before_pick',
|
||||||
|
65: 'manual_picking',
|
||||||
|
70: 'in_pt',
|
||||||
|
80: 'picked',
|
||||||
|
90: 'awaiting_shipment',
|
||||||
|
91: 'remote_wait',
|
||||||
|
92: 'awaiting_pickup',
|
||||||
|
93: 'fix_before_ship',
|
||||||
|
95: 'shipped_confirmed',
|
||||||
|
100: 'shipped'
|
||||||
|
};
|
||||||
let recordsAdded = 0;
|
let recordsAdded = 0;
|
||||||
let recordsUpdated = 0;
|
let recordsUpdated = 0;
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
@@ -31,8 +58,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
|
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'orders'"
|
||||||
);
|
);
|
||||||
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
||||||
|
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||||
|
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||||
|
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||||
|
: lastSyncTime;
|
||||||
|
|
||||||
console.log('Orders: Using last sync time:', lastSyncTime);
|
console.log('Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
|
||||||
|
|
||||||
// First get count of order items - Keep MySQL compatible for production
|
// First get count of order items - Keep MySQL compatible for production
|
||||||
const [[{ total }]] = await prodConnection.query(`
|
const [[{ total }]] = await prodConnection.query(`
|
||||||
@@ -46,11 +77,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
AND (
|
AND (
|
||||||
o.stamp > ?
|
o.stamp > ?
|
||||||
OR oi.stamp > ?
|
OR oi.stamp > ?
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM order_discount_items odi
|
|
||||||
WHERE odi.order_id = o.order_id
|
|
||||||
AND odi.pid = oi.prod_pid
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM order_tax_info oti
|
SELECT 1 FROM order_tax_info oti
|
||||||
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
||||||
@@ -60,7 +86,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
` : ''}
|
` : ''}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
totalOrderItems = total;
|
totalOrderItems = total;
|
||||||
console.log('Orders: Found changes:', totalOrderItems);
|
console.log('Orders: Found changes:', totalOrderItems);
|
||||||
@@ -85,11 +111,6 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
AND (
|
AND (
|
||||||
o.stamp > ?
|
o.stamp > ?
|
||||||
OR oi.stamp > ?
|
OR oi.stamp > ?
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM order_discount_items odi
|
|
||||||
WHERE odi.order_id = o.order_id
|
|
||||||
AND odi.pid = oi.prod_pid
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1 FROM order_tax_info oti
|
SELECT 1 FROM order_tax_info oti
|
||||||
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
JOIN order_tax_info_products otip ON oti.taxinfo_id = otip.taxinfo_id
|
||||||
@@ -99,7 +120,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
` : ''}
|
` : ''}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
console.log('Orders: Found', orderItems.length, 'order items to process');
|
console.log('Orders: Found', orderItems.length, 'order items to process');
|
||||||
|
|
||||||
@@ -284,7 +305,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
|
new Date(order.date), // Convert to TIMESTAMP WITH TIME ZONE
|
||||||
order.customer,
|
order.customer,
|
||||||
toTitleCase(order.customer_name) || '',
|
toTitleCase(order.customer_name) || '',
|
||||||
order.status.toString(), // Convert status to TEXT
|
orderStatusMap[order.status] || order.status.toString(), // Map numeric status to text
|
||||||
order.canceled,
|
order.canceled,
|
||||||
order.summary_discount || 0,
|
order.summary_discount || 0,
|
||||||
order.summary_subtotal || 0,
|
order.summary_subtotal || 0,
|
||||||
@@ -513,11 +534,12 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process all data types SEQUENTIALLY for each batch - not in parallel
|
// Process all data types for each batch
|
||||||
|
// Note: these run sequentially because they share a single PG connection
|
||||||
|
// and each manages its own transaction
|
||||||
for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
|
for (let i = 0; i < orderIds.length; i += METADATA_BATCH_SIZE) {
|
||||||
const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
|
const batchIds = orderIds.slice(i, i + METADATA_BATCH_SIZE);
|
||||||
|
|
||||||
// Run these sequentially instead of in parallel to avoid transaction conflicts
|
|
||||||
await processMetadataBatch(batchIds);
|
await processMetadataBatch(batchIds);
|
||||||
await processDiscountsBatch(batchIds);
|
await processDiscountsBatch(batchIds);
|
||||||
await processTaxesBatch(batchIds);
|
await processTaxesBatch(batchIds);
|
||||||
@@ -536,17 +558,37 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-check all products at once
|
// Pre-check all products and preload cost_price into a temp table
|
||||||
|
// This avoids joining public.products in every sub-batch query (was causing 2x slowdown)
|
||||||
const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
|
const allOrderPids = [...new Set(orderItems.map(item => item.prod_pid))];
|
||||||
console.log('Orders: Checking', allOrderPids.length, 'unique products');
|
console.log('Orders: Checking', allOrderPids.length, 'unique products');
|
||||||
|
|
||||||
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
|
const [existingProducts] = allOrderPids.length > 0 ? await localConnection.query(
|
||||||
"SELECT pid FROM products WHERE pid = ANY($1::bigint[])",
|
"SELECT pid, cost_price FROM products WHERE pid = ANY($1::bigint[])",
|
||||||
[allOrderPids]
|
[allOrderPids]
|
||||||
) : [[]];
|
) : [{ rows: [] }];
|
||||||
|
|
||||||
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
|
const existingPids = new Set(existingProducts.rows.map(p => p.pid));
|
||||||
|
|
||||||
|
// Create temp table with product cost_price for fast lookup in sub-batch queries
|
||||||
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS temp_product_costs;
|
||||||
|
CREATE TEMP TABLE temp_product_costs (
|
||||||
|
pid BIGINT PRIMARY KEY,
|
||||||
|
cost_price NUMERIC(14, 4)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
if (existingProducts.rows.length > 0) {
|
||||||
|
const costPids = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.pid);
|
||||||
|
const costPrices = existingProducts.rows.filter(p => p.cost_price != null).map(p => p.cost_price);
|
||||||
|
if (costPids.length > 0) {
|
||||||
|
await localConnection.query(`
|
||||||
|
INSERT INTO temp_product_costs (pid, cost_price)
|
||||||
|
SELECT * FROM UNNEST($1::bigint[], $2::numeric[])
|
||||||
|
`, [costPids, costPrices]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process in smaller batches
|
// Process in smaller batches
|
||||||
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
|
for (let i = 0; i < orderIds.length; i += 2000) { // Increased from 1000 to 2000
|
||||||
const batchIds = orderIds.slice(i, i + 2000);
|
const batchIds = orderIds.slice(i, i + 2000);
|
||||||
@@ -570,14 +612,15 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as promo_discount_sum,
|
END) as promo_discount_sum,
|
||||||
COALESCE(ot.tax, 0) as total_tax,
|
COALESCE(ot.tax, 0) as total_tax,
|
||||||
COALESCE(oc.costeach, oi.price * 0.5) as costeach
|
COALESCE(oc.costeach, pc.cost_price, oi.price * 0.5) as costeach
|
||||||
FROM temp_order_items oi
|
FROM temp_order_items oi
|
||||||
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
|
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
|
||||||
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
|
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
|
||||||
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
||||||
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
|
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
|
||||||
|
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
|
||||||
WHERE oi.order_id = ANY($1)
|
WHERE oi.order_id = ANY($1)
|
||||||
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach
|
GROUP BY oi.order_id, oi.pid, ot.tax, oc.costeach, pc.cost_price
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
oi.order_id as order_number,
|
oi.order_id as order_number,
|
||||||
@@ -587,17 +630,14 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
oi.price,
|
oi.price,
|
||||||
oi.quantity,
|
oi.quantity,
|
||||||
(
|
(
|
||||||
-- Part 1: Sale Savings for the Line
|
-- Prorated Points Discount (e.g. loyalty points applied at order level)
|
||||||
(oi.base_discount * oi.quantity)
|
|
||||||
+
|
|
||||||
-- Part 2: Prorated Points Discount (if applicable)
|
|
||||||
CASE
|
CASE
|
||||||
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
|
WHEN om.summary_discount_subtotal > 0 AND om.summary_subtotal > 0 THEN
|
||||||
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
|
COALESCE(ROUND((om.summary_discount_subtotal * (oi.price * oi.quantity)) / NULLIF(om.summary_subtotal, 0), 4), 0)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
+
|
+
|
||||||
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal)
|
-- Specific Item-Level Promo Discount (coupon codes, etc.)
|
||||||
COALESCE(ot.promo_discount_sum, 0)
|
COALESCE(ot.promo_discount_sum, 0)
|
||||||
)::NUMERIC(14, 4) as discount,
|
)::NUMERIC(14, 4) as discount,
|
||||||
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
|
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
|
||||||
@@ -607,10 +647,11 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
om.customer_name,
|
om.customer_name,
|
||||||
om.status,
|
om.status,
|
||||||
om.canceled,
|
om.canceled,
|
||||||
COALESCE(ot.costeach, oi.price * 0.5)::NUMERIC(14, 4) as costeach
|
COALESCE(ot.costeach, pc.cost_price, oi.price * 0.5)::NUMERIC(14, 4) as costeach
|
||||||
FROM temp_order_items oi
|
FROM temp_order_items oi
|
||||||
JOIN temp_order_meta om ON oi.order_id = om.order_id
|
JOIN temp_order_meta om ON oi.order_id = om.order_id
|
||||||
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
LEFT JOIN order_totals ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
||||||
|
LEFT JOIN temp_product_costs pc ON oi.pid = pc.pid
|
||||||
WHERE oi.order_id = ANY($1)
|
WHERE oi.order_id = ANY($1)
|
||||||
ORDER BY oi.order_id, oi.pid
|
ORDER BY oi.order_id, oi.pid
|
||||||
`, [subBatchIds]);
|
`, [subBatchIds]);
|
||||||
@@ -654,7 +695,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
o.shipping,
|
o.shipping,
|
||||||
o.customer,
|
o.customer,
|
||||||
o.customer_name,
|
o.customer_name,
|
||||||
o.status.toString(), // Convert status to TEXT
|
o.status, // Already mapped to text via orderStatusMap
|
||||||
o.canceled,
|
o.canceled,
|
||||||
o.costeach
|
o.costeach
|
||||||
]);
|
]);
|
||||||
@@ -744,6 +785,7 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
DROP TABLE IF EXISTS temp_order_costs;
|
DROP TABLE IF EXISTS temp_order_costs;
|
||||||
DROP TABLE IF EXISTS temp_main_discounts;
|
DROP TABLE IF EXISTS temp_main_discounts;
|
||||||
DROP TABLE IF EXISTS temp_item_discounts;
|
DROP TABLE IF EXISTS temp_item_discounts;
|
||||||
|
DROP TABLE IF EXISTS temp_product_costs;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Commit final transaction
|
// Commit final transaction
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ async function setupTemporaryTables(connection) {
|
|||||||
artist TEXT,
|
artist TEXT,
|
||||||
categories TEXT,
|
categories TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE,
|
created_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
date_online TIMESTAMP WITH TIME ZONE,
|
||||||
first_received TIMESTAMP WITH TIME ZONE,
|
first_received TIMESTAMP WITH TIME ZONE,
|
||||||
landing_cost_price NUMERIC(14, 4),
|
|
||||||
barcode TEXT,
|
barcode TEXT,
|
||||||
harmonized_tariff_code TEXT,
|
harmonized_tariff_code TEXT,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE,
|
updated_at TIMESTAMP WITH TIME ZONE,
|
||||||
@@ -98,6 +98,7 @@ async function setupTemporaryTables(connection) {
|
|||||||
baskets INTEGER,
|
baskets INTEGER,
|
||||||
notifies INTEGER,
|
notifies INTEGER,
|
||||||
date_last_sold TIMESTAMP WITH TIME ZONE,
|
date_last_sold TIMESTAMP WITH TIME ZONE,
|
||||||
|
shop_score NUMERIC(10, 2) DEFAULT 0,
|
||||||
primary_iid INTEGER,
|
primary_iid INTEGER,
|
||||||
image TEXT,
|
image TEXT,
|
||||||
image_175 TEXT,
|
image_175 TEXT,
|
||||||
@@ -137,6 +138,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
p.notes AS description,
|
p.notes AS description,
|
||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.date_created,
|
p.date_created,
|
||||||
|
p.date_ol,
|
||||||
p.datein AS first_received,
|
p.datein AS first_received,
|
||||||
p.location,
|
p.location,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
@@ -169,7 +171,6 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
)
|
)
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||||
END AS cost_price,
|
END AS cost_price,
|
||||||
NULL as landing_cost_price,
|
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
CASE
|
CASE
|
||||||
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
||||||
@@ -199,6 +200,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
||||||
pls.date_sold as date_last_sold,
|
pls.date_sold as date_last_sold,
|
||||||
|
COALESCE(p.score, 0) as shop_score,
|
||||||
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
||||||
GROUP_CONCAT(DISTINCT CASE
|
GROUP_CONCAT(DISTINCT CASE
|
||||||
WHEN pc.cat_id IS NOT NULL
|
WHEN pc.cat_id IS NOT NULL
|
||||||
@@ -238,8 +240,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
const batch = prodData.slice(i, i + BATCH_SIZE);
|
const batch = prodData.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
const placeholders = batch.map((_, idx) => {
|
const placeholders = batch.map((_, idx) => {
|
||||||
const base = idx * 48; // 48 columns
|
const base = idx * 49; // 49 columns
|
||||||
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
const values = batch.flatMap(row => {
|
||||||
@@ -264,8 +266,8 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
row.artist,
|
row.artist,
|
||||||
row.category_ids,
|
row.category_ids,
|
||||||
validateDate(row.date_created),
|
validateDate(row.date_created),
|
||||||
|
validateDate(row.date_ol),
|
||||||
validateDate(row.first_received),
|
validateDate(row.first_received),
|
||||||
row.landing_cost_price,
|
|
||||||
row.barcode,
|
row.barcode,
|
||||||
row.harmonized_tariff_code,
|
row.harmonized_tariff_code,
|
||||||
validateDate(row.updated_at),
|
validateDate(row.updated_at),
|
||||||
@@ -287,6 +289,7 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
row.baskets,
|
row.baskets,
|
||||||
row.notifies,
|
row.notifies,
|
||||||
validateDate(row.date_last_sold),
|
validateDate(row.date_last_sold),
|
||||||
|
Number(row.shop_score) || 0,
|
||||||
row.primary_iid,
|
row.primary_iid,
|
||||||
imageUrls.image,
|
imageUrls.image,
|
||||||
imageUrls.image_175,
|
imageUrls.image_175,
|
||||||
@@ -301,11 +304,11 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
INSERT INTO products (
|
INSERT INTO products (
|
||||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||||
brand, line, subline, artist, categories, created_at, first_received,
|
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
barcode, harmonized_tariff_code, updated_at, visible,
|
||||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||||
weight, length, width, height, country_of_origin, location, total_sold,
|
weight, length, width, height, country_of_origin, location, total_sold,
|
||||||
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
|
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
|
||||||
)
|
)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
ON CONFLICT (pid) DO NOTHING
|
ON CONFLICT (pid) DO NOTHING
|
||||||
@@ -343,6 +346,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
p.notes AS description,
|
p.notes AS description,
|
||||||
p.itemnumber AS sku,
|
p.itemnumber AS sku,
|
||||||
p.date_created,
|
p.date_created,
|
||||||
|
p.date_ol,
|
||||||
p.datein AS first_received,
|
p.datein AS first_received,
|
||||||
p.location,
|
p.location,
|
||||||
p.upc AS barcode,
|
p.upc AS barcode,
|
||||||
@@ -375,7 +379,6 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
)
|
)
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
||||||
END AS cost_price,
|
END AS cost_price,
|
||||||
NULL as landing_cost_price,
|
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
CASE
|
CASE
|
||||||
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
WHEN s.companyname = 'Notions' THEN sid.notions_itemnumber
|
||||||
@@ -405,6 +408,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
JOIN _order o ON oi.order_id = o.order_id
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
||||||
pls.date_sold as date_last_sold,
|
pls.date_sold as date_last_sold,
|
||||||
|
COALESCE(p.score, 0) as shop_score,
|
||||||
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
||||||
GROUP_CONCAT(DISTINCT CASE
|
GROUP_CONCAT(DISTINCT CASE
|
||||||
WHEN pc.cat_id IS NOT NULL
|
WHEN pc.cat_id IS NOT NULL
|
||||||
@@ -427,16 +431,15 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
|
LEFT JOIN product_categories pc4 ON p.artist = pc4.cat_id
|
||||||
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
|
LEFT JOIN product_last_sold pls ON p.pid = pls.pid
|
||||||
WHERE ${incrementalUpdate ? `
|
WHERE ${incrementalUpdate ? `
|
||||||
|
p.date_created >= DATE(?) OR
|
||||||
p.stamp > ? OR
|
p.stamp > ? OR
|
||||||
ci.stamp > ? OR
|
ci.stamp > ? OR
|
||||||
pcp.date_deactive > ? OR
|
pcp.date_deactive > ? OR
|
||||||
pcp.date_active > ? OR
|
pcp.date_active > ? OR
|
||||||
pnb.date_updated > ?
|
pnb.date_updated > ?
|
||||||
-- Add condition for product_images changes if needed for incremental updates
|
|
||||||
-- OR EXISTS (SELECT 1 FROM product_images pi WHERE pi.pid = p.pid AND pi.stamp > ?)
|
|
||||||
` : 'TRUE'}
|
` : 'TRUE'}
|
||||||
GROUP BY p.pid
|
GROUP BY p.pid
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime /*, lastSyncTime */] : []);
|
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
||||||
|
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -450,8 +453,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
|
|
||||||
await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
const placeholders = batch.map((_, idx) => {
|
const placeholders = batch.map((_, idx) => {
|
||||||
const base = idx * 48; // 48 columns
|
const base = idx * 49; // 49 columns
|
||||||
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 49 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
const values = batch.flatMap(row => {
|
||||||
@@ -476,8 +479,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
row.artist,
|
row.artist,
|
||||||
row.category_ids,
|
row.category_ids,
|
||||||
validateDate(row.date_created),
|
validateDate(row.date_created),
|
||||||
|
validateDate(row.date_ol),
|
||||||
validateDate(row.first_received),
|
validateDate(row.first_received),
|
||||||
row.landing_cost_price,
|
|
||||||
row.barcode,
|
row.barcode,
|
||||||
row.harmonized_tariff_code,
|
row.harmonized_tariff_code,
|
||||||
validateDate(row.updated_at),
|
validateDate(row.updated_at),
|
||||||
@@ -499,6 +502,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
row.baskets,
|
row.baskets,
|
||||||
row.notifies,
|
row.notifies,
|
||||||
validateDate(row.date_last_sold),
|
validateDate(row.date_last_sold),
|
||||||
|
Number(row.shop_score) || 0,
|
||||||
row.primary_iid,
|
row.primary_iid,
|
||||||
imageUrls.image,
|
imageUrls.image,
|
||||||
imageUrls.image_175,
|
imageUrls.image_175,
|
||||||
@@ -512,11 +516,11 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
INSERT INTO temp_products (
|
INSERT INTO temp_products (
|
||||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||||
brand, line, subline, artist, categories, created_at, first_received,
|
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
barcode, harmonized_tariff_code, updated_at, visible,
|
||||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||||
weight, length, width, height, country_of_origin, location, total_sold,
|
weight, length, width, height, country_of_origin, location, total_sold,
|
||||||
baskets, notifies, date_last_sold, primary_iid, image, image_175, image_full, options, tags
|
baskets, notifies, date_last_sold, shop_score, primary_iid, image, image_175, image_full, options, tags
|
||||||
) VALUES ${placeholders}
|
) VALUES ${placeholders}
|
||||||
ON CONFLICT (pid) DO UPDATE SET
|
ON CONFLICT (pid) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
@@ -536,8 +540,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
subline = EXCLUDED.subline,
|
subline = EXCLUDED.subline,
|
||||||
artist = EXCLUDED.artist,
|
artist = EXCLUDED.artist,
|
||||||
created_at = EXCLUDED.created_at,
|
created_at = EXCLUDED.created_at,
|
||||||
|
date_online = EXCLUDED.date_online,
|
||||||
first_received = EXCLUDED.first_received,
|
first_received = EXCLUDED.first_received,
|
||||||
landing_cost_price = EXCLUDED.landing_cost_price,
|
|
||||||
barcode = EXCLUDED.barcode,
|
barcode = EXCLUDED.barcode,
|
||||||
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
@@ -559,6 +563,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
baskets = EXCLUDED.baskets,
|
baskets = EXCLUDED.baskets,
|
||||||
notifies = EXCLUDED.notifies,
|
notifies = EXCLUDED.notifies,
|
||||||
date_last_sold = EXCLUDED.date_last_sold,
|
date_last_sold = EXCLUDED.date_last_sold,
|
||||||
|
shop_score = EXCLUDED.shop_score,
|
||||||
primary_iid = EXCLUDED.primary_iid,
|
primary_iid = EXCLUDED.primary_iid,
|
||||||
image = EXCLUDED.image,
|
image = EXCLUDED.image,
|
||||||
image_175 = EXCLUDED.image_175,
|
image_175 = EXCLUDED.image_175,
|
||||||
@@ -615,8 +620,8 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
AND t.barcode IS NOT DISTINCT FROM p.barcode
|
AND t.barcode IS NOT DISTINCT FROM p.barcode
|
||||||
AND t.updated_at IS NOT DISTINCT FROM p.updated_at
|
AND t.updated_at IS NOT DISTINCT FROM p.updated_at
|
||||||
AND t.total_sold IS NOT DISTINCT FROM p.total_sold
|
AND t.total_sold IS NOT DISTINCT FROM p.total_sold
|
||||||
-- Check key fields that are likely to change
|
AND t.date_online IS NOT DISTINCT FROM p.date_online
|
||||||
-- We don't need to check every single field, just the important ones
|
AND t.shop_score IS NOT DISTINCT FROM p.shop_score
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get count of products that need updating
|
// Get count of products that need updating
|
||||||
@@ -664,8 +669,13 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
// Setup temporary tables
|
// Setup temporary tables
|
||||||
await setupTemporaryTables(localConnection);
|
await setupTemporaryTables(localConnection);
|
||||||
|
|
||||||
|
// Adjust sync time for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||||
|
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||||
|
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||||
|
: lastSyncTime;
|
||||||
|
|
||||||
// Materialize calculations into temp table
|
// Materialize calculations into temp table
|
||||||
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, lastSyncTime, startTime);
|
const materializeResult = await materializeCalculations(prodConnection, localConnection, incrementalUpdate, mysqlSyncTime, startTime);
|
||||||
|
|
||||||
// Get the list of products that need updating
|
// Get the list of products that need updating
|
||||||
const [products] = await localConnection.query(`
|
const [products] = await localConnection.query(`
|
||||||
@@ -689,8 +699,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
t.artist,
|
t.artist,
|
||||||
t.categories,
|
t.categories,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
|
t.date_online,
|
||||||
t.first_received,
|
t.first_received,
|
||||||
t.landing_cost_price,
|
|
||||||
t.barcode,
|
t.barcode,
|
||||||
t.harmonized_tariff_code,
|
t.harmonized_tariff_code,
|
||||||
t.updated_at,
|
t.updated_at,
|
||||||
@@ -711,6 +721,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
t.baskets,
|
t.baskets,
|
||||||
t.notifies,
|
t.notifies,
|
||||||
t.date_last_sold,
|
t.date_last_sold,
|
||||||
|
t.shop_score,
|
||||||
t.primary_iid,
|
t.primary_iid,
|
||||||
t.image,
|
t.image,
|
||||||
t.image_175,
|
t.image_175,
|
||||||
@@ -729,8 +740,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
const batch = products.rows.slice(i, i + BATCH_SIZE);
|
const batch = products.rows.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
const placeholders = batch.map((_, idx) => {
|
const placeholders = batch.map((_, idx) => {
|
||||||
const base = idx * 47; // 47 columns
|
const base = idx * 48; // 48 columns (no primary_iid in this INSERT)
|
||||||
return `(${Array.from({ length: 47 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
return `(${Array.from({ length: 48 }, (_, i) => `$${base + i + 1}`).join(', ')})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(row => {
|
const values = batch.flatMap(row => {
|
||||||
@@ -755,8 +766,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
row.artist,
|
row.artist,
|
||||||
row.categories,
|
row.categories,
|
||||||
validateDate(row.created_at),
|
validateDate(row.created_at),
|
||||||
|
validateDate(row.date_online),
|
||||||
validateDate(row.first_received),
|
validateDate(row.first_received),
|
||||||
row.landing_cost_price,
|
|
||||||
row.barcode,
|
row.barcode,
|
||||||
row.harmonized_tariff_code,
|
row.harmonized_tariff_code,
|
||||||
validateDate(row.updated_at),
|
validateDate(row.updated_at),
|
||||||
@@ -778,6 +789,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
row.baskets,
|
row.baskets,
|
||||||
row.notifies,
|
row.notifies,
|
||||||
validateDate(row.date_last_sold),
|
validateDate(row.date_last_sold),
|
||||||
|
Number(row.shop_score) || 0,
|
||||||
imageUrls.image,
|
imageUrls.image,
|
||||||
imageUrls.image_175,
|
imageUrls.image_175,
|
||||||
imageUrls.image_full,
|
imageUrls.image_full,
|
||||||
@@ -791,11 +803,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
INSERT INTO products (
|
INSERT INTO products (
|
||||||
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
pid, title, description, sku, stock_quantity, preorder_count, notions_inv_count,
|
||||||
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
price, regular_price, cost_price, vendor, vendor_reference, notions_reference,
|
||||||
brand, line, subline, artist, categories, created_at, first_received,
|
brand, line, subline, artist, categories, created_at, date_online, first_received,
|
||||||
landing_cost_price, barcode, harmonized_tariff_code, updated_at, visible,
|
barcode, harmonized_tariff_code, updated_at, visible,
|
||||||
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
managing_stock, replenishable, permalink, moq, uom, rating, reviews,
|
||||||
weight, length, width, height, country_of_origin, location, total_sold,
|
weight, length, width, height, country_of_origin, location, total_sold,
|
||||||
baskets, notifies, date_last_sold, image, image_175, image_full, options, tags
|
baskets, notifies, date_last_sold, shop_score, image, image_175, image_full, options, tags
|
||||||
)
|
)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
ON CONFLICT (pid) DO UPDATE SET
|
ON CONFLICT (pid) DO UPDATE SET
|
||||||
@@ -816,8 +828,8 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
subline = EXCLUDED.subline,
|
subline = EXCLUDED.subline,
|
||||||
artist = EXCLUDED.artist,
|
artist = EXCLUDED.artist,
|
||||||
created_at = EXCLUDED.created_at,
|
created_at = EXCLUDED.created_at,
|
||||||
|
date_online = EXCLUDED.date_online,
|
||||||
first_received = EXCLUDED.first_received,
|
first_received = EXCLUDED.first_received,
|
||||||
landing_cost_price = EXCLUDED.landing_cost_price,
|
|
||||||
barcode = EXCLUDED.barcode,
|
barcode = EXCLUDED.barcode,
|
||||||
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
harmonized_tariff_code = EXCLUDED.harmonized_tariff_code,
|
||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
@@ -839,6 +851,7 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
baskets = EXCLUDED.baskets,
|
baskets = EXCLUDED.baskets,
|
||||||
notifies = EXCLUDED.notifies,
|
notifies = EXCLUDED.notifies,
|
||||||
date_last_sold = EXCLUDED.date_last_sold,
|
date_last_sold = EXCLUDED.date_last_sold,
|
||||||
|
shop_score = EXCLUDED.shop_score,
|
||||||
image = EXCLUDED.image,
|
image = EXCLUDED.image,
|
||||||
image_175 = EXCLUDED.image_175,
|
image_175 = EXCLUDED.image_175,
|
||||||
image_full = EXCLUDED.image_full,
|
image_full = EXCLUDED.image_full,
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
|
"SELECT last_sync_timestamp FROM sync_status WHERE table_name = 'purchase_orders'"
|
||||||
);
|
);
|
||||||
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
const lastSyncTime = syncInfo?.rows?.[0]?.last_sync_timestamp || '1970-01-01';
|
||||||
|
// Adjust for mysql2 driver timezone vs MySQL server timezone mismatch
|
||||||
|
const mysqlSyncTime = prodConnection.adjustDateForMySQL
|
||||||
|
? prodConnection.adjustDateForMySQL(lastSyncTime)
|
||||||
|
: lastSyncTime;
|
||||||
|
|
||||||
console.log('Purchase Orders: Using last sync time:', lastSyncTime);
|
console.log('Purchase Orders: Using last sync time:', lastSyncTime, '(adjusted:', mysqlSyncTime, ')');
|
||||||
|
|
||||||
// Create temp tables for processing
|
// Create temp tables for processing
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
@@ -254,7 +258,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
OR p.date_estin > ?
|
OR p.date_estin > ?
|
||||||
)
|
)
|
||||||
` : ''}
|
` : ''}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
const totalPOs = poCount[0].total;
|
const totalPOs = poCount[0].total;
|
||||||
console.log(`Found ${totalPOs} relevant purchase orders`);
|
console.log(`Found ${totalPOs} relevant purchase orders`);
|
||||||
@@ -291,7 +295,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
` : ''}
|
` : ''}
|
||||||
ORDER BY p.po_id
|
ORDER BY p.po_id
|
||||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
if (poList.length === 0) {
|
if (poList.length === 0) {
|
||||||
allPOsProcessed = true;
|
allPOsProcessed = true;
|
||||||
@@ -426,7 +430,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
OR r.date_created > ?
|
OR r.date_created > ?
|
||||||
)
|
)
|
||||||
` : ''}
|
` : ''}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
const totalReceivings = receivingCount[0].total;
|
const totalReceivings = receivingCount[0].total;
|
||||||
console.log(`Found ${totalReceivings} relevant receivings`);
|
console.log(`Found ${totalReceivings} relevant receivings`);
|
||||||
@@ -463,7 +467,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
` : ''}
|
` : ''}
|
||||||
ORDER BY r.receiving_id
|
ORDER BY r.receiving_id
|
||||||
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
LIMIT ${PO_BATCH_SIZE} OFFSET ${offset}
|
||||||
`, incrementalUpdate ? [lastSyncTime, lastSyncTime] : []);
|
`, incrementalUpdate ? [mysqlSyncTime, mysqlSyncTime] : []);
|
||||||
|
|
||||||
if (receivingList.length === 0) {
|
if (receivingList.length === 0) {
|
||||||
allReceivingsProcessed = true;
|
allReceivingsProcessed = true;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
const { outputProgress, formatElapsedTime, calculateRate } = require('../metrics-new/utils/progress');
|
||||||
|
|
||||||
|
const BATCH_SIZE = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports daily stock snapshots from MySQL's snap_product_value table to PostgreSQL.
|
||||||
|
* This provides historical end-of-day stock quantities per product, dating back to 2012.
|
||||||
|
*
|
||||||
|
* MySQL source table: snap_product_value (date, pid, count, pending, value)
|
||||||
|
* - date: snapshot date (typically yesterday's date, recorded daily by cron)
|
||||||
|
* - pid: product ID
|
||||||
|
* - count: end-of-day stock quantity (sum of product_inventory.count)
|
||||||
|
* - pending: pending/on-order quantity
|
||||||
|
* - value: total inventory value at cost (sum of costeach * count)
|
||||||
|
*
|
||||||
|
* PostgreSQL target table: stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||||
|
*
|
||||||
|
* @param {object} prodConnection - MySQL connection to production DB
|
||||||
|
* @param {object} localConnection - PostgreSQL connection wrapper
|
||||||
|
* @param {boolean} incrementalUpdate - If true, only fetch new snapshots since last import
|
||||||
|
* @returns {object} Import statistics
|
||||||
|
*/
|
||||||
|
async function importStockSnapshots(prodConnection, localConnection, incrementalUpdate = true) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: 'Starting stock snapshots import...',
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
elapsed: formatElapsedTime(startTime)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure target table exists
|
||||||
|
await localConnection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_snapshots (
|
||||||
|
snapshot_date DATE NOT NULL,
|
||||||
|
pid BIGINT NOT NULL,
|
||||||
|
stock_quantity INT NOT NULL DEFAULT 0,
|
||||||
|
pending_quantity INT NOT NULL DEFAULT 0,
|
||||||
|
stock_value NUMERIC(14, 4) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (snapshot_date, pid)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index for efficient lookups by pid
|
||||||
|
await localConnection.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_snapshots_pid ON stock_snapshots (pid)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Determine the start date for the import
|
||||||
|
let startDate = '2020-01-01'; // Default: match the orders/snapshots date range
|
||||||
|
if (incrementalUpdate) {
|
||||||
|
const [result] = await localConnection.query(`
|
||||||
|
SELECT MAX(snapshot_date)::text AS max_date FROM stock_snapshots
|
||||||
|
`);
|
||||||
|
if (result.rows[0]?.max_date) {
|
||||||
|
// Start from the day after the last imported date
|
||||||
|
startDate = result.rows[0].max_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: `Fetching stock snapshots from MySQL since ${startDate}...`,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
elapsed: formatElapsedTime(startTime)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count total rows to import
|
||||||
|
const [countResult] = await prodConnection.query(
|
||||||
|
`SELECT COUNT(*) AS total FROM snap_product_value WHERE date > ?`,
|
||||||
|
[startDate]
|
||||||
|
);
|
||||||
|
const totalRows = countResult[0].total;
|
||||||
|
|
||||||
|
if (totalRows === 0) {
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: 'No new stock snapshots to import',
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
elapsed: formatElapsedTime(startTime)
|
||||||
|
});
|
||||||
|
return { recordsAdded: 0, recordsUpdated: 0, status: 'complete' };
|
||||||
|
}
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: `Found ${totalRows.toLocaleString()} stock snapshot rows to import`,
|
||||||
|
current: 0,
|
||||||
|
total: totalRows,
|
||||||
|
elapsed: formatElapsedTime(startTime)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process in batches using date-based pagination (more efficient than OFFSET)
|
||||||
|
let processedRows = 0;
|
||||||
|
let recordsAdded = 0;
|
||||||
|
let currentDate = startDate;
|
||||||
|
|
||||||
|
while (processedRows < totalRows) {
|
||||||
|
// Fetch a batch of dates
|
||||||
|
const [dateBatch] = await prodConnection.query(
|
||||||
|
`SELECT DISTINCT date FROM snap_product_value
|
||||||
|
WHERE date > ? ORDER BY date LIMIT 10`,
|
||||||
|
[currentDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dateBatch.length === 0) break;
|
||||||
|
|
||||||
|
const dates = dateBatch.map(r => r.date);
|
||||||
|
const lastDate = dates[dates.length - 1];
|
||||||
|
|
||||||
|
// Fetch all rows for these dates
|
||||||
|
const [rows] = await prodConnection.query(
|
||||||
|
`SELECT date, pid, count AS stock_quantity, pending AS pending_quantity, value AS stock_value
|
||||||
|
FROM snap_product_value
|
||||||
|
WHERE date > ? AND date <= ?
|
||||||
|
ORDER BY date, pid`,
|
||||||
|
[currentDate, lastDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) break;
|
||||||
|
|
||||||
|
// Batch insert into PostgreSQL using UNNEST for efficiency
|
||||||
|
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
||||||
|
const batch = rows.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
const dates = batch.map(r => r.date);
|
||||||
|
const pids = batch.map(r => r.pid);
|
||||||
|
const quantities = batch.map(r => r.stock_quantity);
|
||||||
|
const pending = batch.map(r => r.pending_quantity);
|
||||||
|
const values = batch.map(r => r.stock_value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await localConnection.query(`
|
||||||
|
INSERT INTO stock_snapshots (snapshot_date, pid, stock_quantity, pending_quantity, stock_value)
|
||||||
|
SELECT * FROM UNNEST(
|
||||||
|
$1::date[], $2::bigint[], $3::int[], $4::int[], $5::numeric[]
|
||||||
|
)
|
||||||
|
ON CONFLICT (snapshot_date, pid) DO UPDATE SET
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
pending_quantity = EXCLUDED.pending_quantity,
|
||||||
|
stock_value = EXCLUDED.stock_value
|
||||||
|
`, [dates, pids, quantities, pending, values]);
|
||||||
|
|
||||||
|
recordsAdded += batch.length;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error inserting batch at offset ${i} (date range ending ${currentDate}):`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedRows += rows.length;
|
||||||
|
currentDate = lastDate;
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: `Imported ${processedRows.toLocaleString()} / ${totalRows.toLocaleString()} rows (through ${currentDate})`,
|
||||||
|
current: processedRows,
|
||||||
|
total: totalRows,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
rate: calculateRate(processedRows, startTime)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Stock snapshots import',
|
||||||
|
message: `Stock snapshots import complete: ${recordsAdded.toLocaleString()} rows`,
|
||||||
|
current: processedRows,
|
||||||
|
total: totalRows,
|
||||||
|
elapsed: formatElapsedTime(startTime)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordsAdded,
|
||||||
|
recordsUpdated: 0,
|
||||||
|
status: 'complete'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = importStockSnapshots;
|
||||||
@@ -48,6 +48,37 @@ async function setupConnections(sshConfig) {
|
|||||||
stream: tunnel.stream,
|
stream: tunnel.stream,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect MySQL server timezone and calculate correction for the driver timezone mismatch.
|
||||||
|
// The mysql2 driver is configured with timezone: '-05:00' (EST), but the MySQL server
|
||||||
|
// may be in a different timezone (e.g., America/Chicago = CST/CDT). When the driver
|
||||||
|
// formats a JS Date as EST and MySQL interprets it in its own timezone, DATETIME
|
||||||
|
// comparisons can be off. This correction adjusts Date objects before they're passed
|
||||||
|
// to MySQL queries so the formatted string matches the server's local time.
|
||||||
|
const [[{ utcDiffSec }]] = await prodConnection.query(
|
||||||
|
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
|
||||||
|
);
|
||||||
|
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
|
||||||
|
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
|
||||||
|
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
|
||||||
|
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
|
||||||
|
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
|
||||||
|
|
||||||
|
if (tzCorrectionMs !== 0) {
|
||||||
|
console.log(`MySQL timezone correction: ${tzCorrectionMs / 1000}s (server offset: ${utcDiffSec}s from UTC)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts a Date/timestamp for the mysql2 driver timezone mismatch before
|
||||||
|
* passing it as a query parameter to MySQL. This ensures that the string
|
||||||
|
* mysql2 generates matches the timezone that DATETIME values are stored in.
|
||||||
|
*/
|
||||||
|
function adjustDateForMySQL(date) {
|
||||||
|
if (!date || tzCorrectionMs === 0) return date;
|
||||||
|
const d = date instanceof Date ? date : new Date(date);
|
||||||
|
return new Date(d.getTime() - tzCorrectionMs);
|
||||||
|
}
|
||||||
|
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
|
||||||
|
|
||||||
// Setup PostgreSQL connection pool for local
|
// Setup PostgreSQL connection pool for local
|
||||||
const localPool = new Pool(sshConfig.localDbConfig);
|
const localPool = new Pool(sshConfig.localDbConfig);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ BEGIN
|
|||||||
p.visible as is_visible, p.replenishable,
|
p.visible as is_visible, p.replenishable,
|
||||||
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
|
COALESCE(p.price, 0.00) as current_price, COALESCE(p.regular_price, 0.00) as current_regular_price,
|
||||||
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
COALESCE(p.cost_price, 0.00) as current_cost_price,
|
||||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as current_effective_cost, -- Use landing if available, else cost
|
COALESCE(p.cost_price, 0.00) as current_effective_cost,
|
||||||
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
||||||
p.created_at, p.first_received, p.date_last_sold,
|
p.created_at, p.first_received, p.date_last_sold,
|
||||||
p.moq,
|
p.moq,
|
||||||
@@ -214,7 +214,7 @@ BEGIN
|
|||||||
-- Final INSERT/UPDATE statement using all the prepared CTEs
|
-- Final INSERT/UPDATE statement using all the prepared CTEs
|
||||||
INSERT INTO public.product_metrics (
|
INSERT INTO public.product_metrics (
|
||||||
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
pid, last_calculated, sku, title, brand, vendor, image_url, is_visible, is_replenishable,
|
||||||
current_price, current_regular_price, current_cost_price, current_landing_cost_price,
|
current_price, current_regular_price, current_cost_price,
|
||||||
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
current_stock, current_stock_cost, current_stock_retail, current_stock_gross,
|
||||||
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
on_order_qty, on_order_cost, on_order_retail, earliest_expected_date,
|
||||||
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
date_created, date_first_received, date_last_received, date_first_sold, date_last_sold, age_days,
|
||||||
@@ -242,7 +242,7 @@ BEGIN
|
|||||||
SELECT
|
SELECT
|
||||||
-- Select columns in order, joining all CTEs by pid
|
-- Select columns in order, joining all CTEs by pid
|
||||||
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable,
|
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.replenishable,
|
||||||
ci.current_price, ci.current_regular_price, ci.current_cost_price, ci.current_effective_cost,
|
ci.current_price, ci.current_regular_price, ci.current_cost_price,
|
||||||
ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2),
|
ci.current_stock, (ci.current_stock * COALESCE(ci.current_effective_cost, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_price, 0.00))::numeric(12,2), (ci.current_stock * COALESCE(ci.current_regular_price, 0.00))::numeric(12,2),
|
||||||
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date,
|
COALESCE(ooi.on_order_qty, 0), COALESCE(ooi.on_order_cost, 0.00)::numeric(12,2), (COALESCE(ooi.on_order_qty, 0) * COALESCE(ci.current_price, 0.00))::numeric(12,2), ooi.earliest_expected_date,
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ BEGIN
|
|||||||
-- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
|
-- *** IMPORTANT: List ALL columns here, ensuring order matches INSERT list ***
|
||||||
-- Update ALL columns to ensure entire row is refreshed
|
-- Update ALL columns to ensure entire row is refreshed
|
||||||
last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
last_calculated = EXCLUDED.last_calculated, sku = EXCLUDED.sku, title = EXCLUDED.title, brand = EXCLUDED.brand, vendor = EXCLUDED.vendor, image_url = EXCLUDED.image_url, is_visible = EXCLUDED.is_visible, is_replenishable = EXCLUDED.is_replenishable,
|
||||||
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price, current_landing_cost_price = EXCLUDED.current_landing_cost_price,
|
current_price = EXCLUDED.current_price, current_regular_price = EXCLUDED.current_regular_price, current_cost_price = EXCLUDED.current_cost_price,
|
||||||
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
current_stock = EXCLUDED.current_stock, current_stock_cost = EXCLUDED.current_stock_cost, current_stock_retail = EXCLUDED.current_stock_retail, current_stock_gross = EXCLUDED.current_stock_gross,
|
||||||
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
on_order_qty = EXCLUDED.on_order_qty, on_order_cost = EXCLUDED.on_order_cost, on_order_retail = EXCLUDED.on_order_retail, earliest_expected_date = EXCLUDED.earliest_expected_date,
|
||||||
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
date_created = EXCLUDED.date_created, date_first_received = EXCLUDED.date_first_received, date_last_received = EXCLUDED.date_last_received, date_first_sold = EXCLUDED.date_first_sold, date_last_sold = EXCLUDED.date_last_sold, age_days = EXCLUDED.age_days,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DECLARE
|
|||||||
_date DATE;
|
_date DATE;
|
||||||
_count INT;
|
_count INT;
|
||||||
_total_records INT := 0;
|
_total_records INT := 0;
|
||||||
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2024-01-01'); -- Starting point for data rebuild
|
_begin_date DATE := (SELECT MIN(date)::date FROM orders WHERE date >= '2020-01-01'); -- Starting point: captures all historical order data
|
||||||
_end_date DATE := CURRENT_DATE;
|
_end_date DATE := CURRENT_DATE;
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
|
RAISE NOTICE 'Beginning daily snapshots rebuild from % to %. Starting at %', _begin_date, _end_date, _start_time;
|
||||||
@@ -36,7 +36,13 @@ BEGIN
|
|||||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
|
||||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
|
||||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
|
||||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
|
||||||
|
COALESCE(
|
||||||
|
o.costeach,
|
||||||
|
get_weighted_avg_cost(p.pid, o.date::date),
|
||||||
|
p.cost_price
|
||||||
|
) * o.quantity
|
||||||
|
ELSE 0 END), 0.00) AS cogs,
|
||||||
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
|
||||||
|
|
||||||
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
||||||
@@ -63,15 +69,17 @@ BEGIN
|
|||||||
GROUP BY r.pid
|
GROUP BY r.pid
|
||||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||||
),
|
),
|
||||||
-- Get stock quantities for the day - note this is approximate since we're using current products data
|
-- Use historical stock from stock_snapshots when available,
|
||||||
|
-- falling back to current stock from products table
|
||||||
StockData AS (
|
StockData AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
p.stock_quantity,
|
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||||
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
|
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||||
COALESCE(p.price, 0.00) as current_price,
|
COALESCE(p.price, 0.00) as current_price,
|
||||||
COALESCE(p.regular_price, 0.00) as current_regular_price
|
COALESCE(p.regular_price, 0.00) as current_regular_price
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
|
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _date
|
||||||
)
|
)
|
||||||
INSERT INTO public.daily_product_snapshots (
|
INSERT INTO public.daily_product_snapshots (
|
||||||
snapshot_date,
|
snapshot_date,
|
||||||
@@ -99,9 +107,9 @@ BEGIN
|
|||||||
_date AS snapshot_date,
|
_date AS snapshot_date,
|
||||||
COALESCE(sd.pid, rd.pid) AS pid,
|
COALESCE(sd.pid, rd.pid) AS pid,
|
||||||
sd.sku,
|
sd.sku,
|
||||||
-- Use current stock as approximation, since historical stock data may not be available
|
-- Historical stock from stock_snapshots, falls back to current stock
|
||||||
s.stock_quantity AS eod_stock_quantity,
|
s.stock_quantity AS eod_stock_quantity,
|
||||||
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
|
s.stock_value AS eod_stock_cost,
|
||||||
s.stock_quantity * s.current_price AS eod_stock_retail,
|
s.stock_quantity * s.current_price AS eod_stock_retail,
|
||||||
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
|
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
|
||||||
(s.stock_quantity <= 0) AS stockout_flag,
|
(s.stock_quantity <= 0) AS stockout_flag,
|
||||||
@@ -111,10 +119,10 @@ BEGIN
|
|||||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||||
COALESCE(sd.discounts, 0.00),
|
COALESCE(sd.discounts, 0.00),
|
||||||
COALESCE(sd.returns_revenue, 0.00),
|
COALESCE(sd.returns_revenue, 0.00),
|
||||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||||
COALESCE(sd.cogs, 0.00),
|
COALESCE(sd.cogs, 0.00),
|
||||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||||
-- Receiving metrics
|
-- Receiving metrics
|
||||||
COALESCE(rd.units_received, 0),
|
COALESCE(rd.units_received, 0),
|
||||||
COALESCE(rd.cost_received, 0.00),
|
COALESCE(rd.cost_received, 0.00),
|
||||||
|
|||||||
@@ -23,21 +23,21 @@ BEGIN
|
|||||||
-- Only include products with valid sales data in each time period
|
-- Only include products with valid sales data in each time period
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||||
FROM public.product_metrics pm
|
FROM public.product_metrics pm
|
||||||
JOIN public.products p ON pm.pid = p.pid
|
JOIN public.products p ON pm.pid = p.pid
|
||||||
GROUP BY brand_group
|
GROUP BY brand_group
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ BEGIN
|
|||||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||||
@@ -38,58 +38,56 @@ BEGIN
|
|||||||
JOIN public.product_metrics pm ON pc.pid = pm.pid
|
JOIN public.product_metrics pm ON pc.pid = pm.pid
|
||||||
GROUP BY pc.cat_id
|
GROUP BY pc.cat_id
|
||||||
),
|
),
|
||||||
-- Calculate rolled-up metrics (including all descendant categories)
|
-- Map each category to ALL distinct products in it or any descendant.
|
||||||
|
-- Uses the path array from category_hierarchy: for product P in category C,
|
||||||
|
-- P contributes to C and every ancestor in C's path.
|
||||||
|
-- DISTINCT ensures each (ancestor, pid) pair appears only once, preventing
|
||||||
|
-- double-counting when a product belongs to multiple categories under the same parent.
|
||||||
|
CategoryProducts AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
ancestor_cat_id,
|
||||||
|
pc.pid
|
||||||
|
FROM public.product_categories pc
|
||||||
|
JOIN category_hierarchy ch ON pc.cat_id = ch.cat_id
|
||||||
|
CROSS JOIN LATERAL unnest(ch.path) AS ancestor_cat_id
|
||||||
|
),
|
||||||
|
-- Calculate rolled-up metrics using deduplicated product sets
|
||||||
RolledUpMetrics AS (
|
RolledUpMetrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
ch.cat_id,
|
cp.ancestor_cat_id AS cat_id,
|
||||||
-- Sum metrics from this category and all its descendants
|
COUNT(DISTINCT cp.pid) AS product_count,
|
||||||
SUM(dcm.product_count) AS product_count,
|
COUNT(DISTINCT CASE WHEN pm.is_visible THEN cp.pid END) AS active_product_count,
|
||||||
SUM(dcm.active_product_count) AS active_product_count,
|
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN cp.pid END) AS replenishable_product_count,
|
||||||
SUM(dcm.replenishable_product_count) AS replenishable_product_count,
|
SUM(pm.current_stock) AS current_stock_units,
|
||||||
SUM(dcm.current_stock_units) AS current_stock_units,
|
SUM(pm.current_stock_cost) AS current_stock_cost,
|
||||||
SUM(dcm.current_stock_cost) AS current_stock_cost,
|
SUM(pm.current_stock_retail) AS current_stock_retail,
|
||||||
SUM(dcm.current_stock_retail) AS current_stock_retail,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(dcm.sales_7d) AS sales_7d,
|
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
||||||
SUM(dcm.revenue_7d) AS revenue_7d,
|
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||||
SUM(dcm.sales_30d) AS sales_30d,
|
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
||||||
SUM(dcm.revenue_30d) AS revenue_30d,
|
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||||
SUM(dcm.cogs_30d) AS cogs_30d,
|
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||||
SUM(dcm.profit_30d) AS profit_30d,
|
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||||
SUM(dcm.sales_365d) AS sales_365d,
|
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
||||||
SUM(dcm.revenue_365d) AS revenue_365d,
|
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||||
SUM(dcm.lifetime_sales) AS lifetime_sales,
|
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
||||||
SUM(dcm.lifetime_revenue) AS lifetime_revenue
|
FROM CategoryProducts cp
|
||||||
FROM category_hierarchy ch
|
JOIN public.product_metrics pm ON cp.pid = pm.pid
|
||||||
LEFT JOIN DirectCategoryMetrics dcm ON
|
GROUP BY cp.ancestor_cat_id
|
||||||
dcm.cat_id = ch.cat_id OR
|
|
||||||
dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
|
||||||
GROUP BY ch.cat_id
|
|
||||||
),
|
),
|
||||||
PreviousPeriodCategoryMetrics AS (
|
-- Previous period rolled up using same deduplicated product sets
|
||||||
-- Get previous period metrics for growth calculation
|
RolledUpPreviousPeriod AS (
|
||||||
SELECT
|
SELECT
|
||||||
pc.cat_id,
|
cp.ancestor_cat_id AS cat_id,
|
||||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||||
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
|
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
|
||||||
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
|
||||||
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
|
||||||
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
|
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
|
||||||
FROM public.daily_product_snapshots dps
|
FROM CategoryProducts cp
|
||||||
JOIN public.product_categories pc ON dps.pid = pc.pid
|
JOIN public.daily_product_snapshots dps ON cp.pid = dps.pid
|
||||||
GROUP BY pc.cat_id
|
GROUP BY cp.ancestor_cat_id
|
||||||
),
|
|
||||||
RolledUpPreviousPeriod AS (
|
|
||||||
-- Calculate rolled-up previous period metrics
|
|
||||||
SELECT
|
|
||||||
ch.cat_id,
|
|
||||||
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
|
|
||||||
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
|
|
||||||
FROM category_hierarchy ch
|
|
||||||
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
|
|
||||||
ppcm.cat_id = ch.cat_id OR
|
|
||||||
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
|
|
||||||
GROUP BY ch.cat_id
|
|
||||||
),
|
),
|
||||||
AllCategories AS (
|
AllCategories AS (
|
||||||
-- Ensure all categories are included
|
-- Ensure all categories are included
|
||||||
|
|||||||
@@ -24,21 +24,21 @@ BEGIN
|
|||||||
-- Only include products with valid sales data in each time period
|
-- Only include products with valid sales data in each time period
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
COUNT(DISTINCT CASE WHEN pm.sales_7d > 0 THEN pm.pid END) AS products_with_sales_7d,
|
||||||
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
|
||||||
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
|
SUM(COALESCE(pm.revenue_7d, 0)) AS revenue_7d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
COUNT(DISTINCT CASE WHEN pm.sales_30d > 0 THEN pm.pid END) AS products_with_sales_30d,
|
||||||
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
|
||||||
SUM(CASE WHEN pm.revenue_30d > 0 THEN pm.revenue_30d ELSE 0 END) AS revenue_30d,
|
SUM(COALESCE(pm.revenue_30d, 0)) AS revenue_30d,
|
||||||
SUM(CASE WHEN pm.cogs_30d > 0 THEN pm.cogs_30d ELSE 0 END) AS cogs_30d,
|
SUM(COALESCE(pm.cogs_30d, 0)) AS cogs_30d,
|
||||||
SUM(CASE WHEN pm.profit_30d != 0 THEN pm.profit_30d ELSE 0 END) AS profit_30d,
|
SUM(COALESCE(pm.profit_30d, 0)) AS profit_30d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
COUNT(DISTINCT CASE WHEN pm.sales_365d > 0 THEN pm.pid END) AS products_with_sales_365d,
|
||||||
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
|
||||||
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
|
SUM(COALESCE(pm.revenue_365d, 0)) AS revenue_365d,
|
||||||
|
|
||||||
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
COUNT(DISTINCT CASE WHEN pm.lifetime_sales > 0 THEN pm.pid END) AS products_with_lifetime_sales,
|
||||||
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
|
||||||
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
|
SUM(COALESCE(pm.lifetime_revenue, 0)) AS lifetime_revenue
|
||||||
FROM public.product_metrics pm
|
FROM public.product_metrics pm
|
||||||
JOIN public.products p ON pm.pid = p.pid
|
JOIN public.products p ON pm.pid = p.pid
|
||||||
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
|
||||||
@@ -72,7 +72,7 @@ BEGIN
|
|||||||
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
||||||
FROM public.purchase_orders po
|
FROM public.purchase_orders po
|
||||||
-- Join to receivings table to find when items were received
|
-- Join to receivings table to find when items were received
|
||||||
LEFT JOIN public.receivings r ON r.pid = po.pid
|
LEFT JOIN public.receivings r ON r.pid = po.pid AND r.supplier_id = po.supplier_id
|
||||||
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
|
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
|
||||||
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
||||||
AND po.status = 'done' -- Only calculate lead time on completed POs
|
AND po.status = 'done' -- Only calculate lead time on completed POs
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Migration: Map existing numeric order statuses to text values
|
||||||
|
-- Run this ONCE on the production PostgreSQL database after deploying the updated orders import.
|
||||||
|
-- This updates ~2.88M rows. On a busy system, consider running during low-traffic hours.
|
||||||
|
-- The WHERE clause ensures idempotency - only rows with numeric statuses are updated.
|
||||||
|
|
||||||
|
UPDATE orders SET status = CASE status
|
||||||
|
WHEN '0' THEN 'created'
|
||||||
|
WHEN '10' THEN 'unfinished'
|
||||||
|
WHEN '15' THEN 'canceled'
|
||||||
|
WHEN '16' THEN 'combined'
|
||||||
|
WHEN '20' THEN 'placed'
|
||||||
|
WHEN '22' THEN 'placed_incomplete'
|
||||||
|
WHEN '30' THEN 'canceled'
|
||||||
|
WHEN '40' THEN 'awaiting_payment'
|
||||||
|
WHEN '50' THEN 'awaiting_products'
|
||||||
|
WHEN '55' THEN 'shipping_later'
|
||||||
|
WHEN '56' THEN 'shipping_together'
|
||||||
|
WHEN '60' THEN 'ready'
|
||||||
|
WHEN '61' THEN 'flagged'
|
||||||
|
WHEN '62' THEN 'fix_before_pick'
|
||||||
|
WHEN '65' THEN 'manual_picking'
|
||||||
|
WHEN '70' THEN 'in_pt'
|
||||||
|
WHEN '80' THEN 'picked'
|
||||||
|
WHEN '90' THEN 'awaiting_shipment'
|
||||||
|
WHEN '91' THEN 'remote_wait'
|
||||||
|
WHEN '92' THEN 'awaiting_pickup'
|
||||||
|
WHEN '93' THEN 'fix_before_ship'
|
||||||
|
WHEN '95' THEN 'shipped_confirmed'
|
||||||
|
WHEN '100' THEN 'shipped'
|
||||||
|
ELSE status
|
||||||
|
END
|
||||||
|
WHERE status ~ '^\d+$'; -- Only update rows that still have numeric statuses
|
||||||
|
|
||||||
|
-- Verify the migration
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM orders
|
||||||
|
GROUP BY status
|
||||||
|
ORDER BY count DESC;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Migration 002: Fix discount double-counting in orders
|
||||||
|
--
|
||||||
|
-- PROBLEM: The orders import was calculating discount as:
|
||||||
|
-- discount = (prod_price_reg - prod_price) * quantity <-- "sale savings" (WRONG)
|
||||||
|
-- + prorated points discount
|
||||||
|
-- + item-level promo discounts
|
||||||
|
--
|
||||||
|
-- Since `price` in the orders table already IS the sale price (prod_price, not prod_price_reg),
|
||||||
|
-- the "sale savings" component double-counted the markdown. This resulted in inflated discounts
|
||||||
|
-- and near-zero net_revenue for products sold on sale.
|
||||||
|
--
|
||||||
|
-- Example: Product with regular_price=$30, sale_price=$15, qty=2
|
||||||
|
-- BEFORE (buggy): discount = ($30-$15)*2 + 0 + 0 = $30.00
|
||||||
|
-- net_revenue = $15*2 - $30 = $0.00 (WRONG!)
|
||||||
|
-- AFTER (fixed): discount = 0 + 0 + 0 = $0.00
|
||||||
|
-- net_revenue = $15*2 - $0 = $30.00 (CORRECT!)
|
||||||
|
--
|
||||||
|
-- FIX: This cannot be fixed with a pure SQL migration because PostgreSQL doesn't store
|
||||||
|
-- prod_price_reg. The discount column has the inflated value baked in, and we can't
|
||||||
|
-- decompose which portion was the base_discount vs actual promo discounts.
|
||||||
|
--
|
||||||
|
-- REQUIRED ACTION: Run a FULL (non-incremental) orders re-import after deploying the
|
||||||
|
-- fixed orders.js. This will recalculate all discounts using the corrected formula.
|
||||||
|
--
|
||||||
|
-- Steps:
|
||||||
|
-- 1. Deploy updated orders.js (base_discount removed from discount calculation)
|
||||||
|
-- 2. Run: node scripts/import/orders.js --full
|
||||||
|
-- (or trigger a full sync through whatever mechanism is used)
|
||||||
|
-- 3. After re-import, run the daily snapshots rebuild to propagate corrected revenue:
|
||||||
|
-- psql -f scripts/metrics-new/backfill/rebuild_daily_snapshots.sql
|
||||||
|
-- 4. Re-run metrics calculation:
|
||||||
|
-- node scripts/metrics-new/calculate-metrics-new.js
|
||||||
|
--
|
||||||
|
-- VERIFICATION: After re-import, check the previously-affected products:
|
||||||
|
SELECT
|
||||||
|
o.pid,
|
||||||
|
p.title,
|
||||||
|
o.order_number,
|
||||||
|
o.price,
|
||||||
|
o.quantity,
|
||||||
|
o.discount,
|
||||||
|
(o.price * o.quantity) as gross_revenue,
|
||||||
|
(o.price * o.quantity - o.discount) as net_revenue
|
||||||
|
FROM orders o
|
||||||
|
JOIN products p ON o.pid = p.pid
|
||||||
|
WHERE o.pid IN (624756, 614513)
|
||||||
|
ORDER BY o.date DESC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- Expected: discount should be 0 (or small promo amount) for regular sales,
|
||||||
|
-- and net_revenue should be close to gross_revenue.
|
||||||
@@ -1,75 +1,109 @@
|
|||||||
-- Description: Calculates and updates daily aggregated product data for recent days.
|
-- Description: Calculates and updates daily aggregated product data.
|
||||||
-- Uses UPSERT (INSERT ON CONFLICT UPDATE) for idempotency.
|
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
|
||||||
|
-- aggregates that don't match source tables after backfills), and always
|
||||||
|
-- reprocesses recent days to pick up new orders and data corrections.
|
||||||
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
|
-- Dependencies: Core import tables (products, orders, purchase_orders), calculate_status table.
|
||||||
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
|
-- Frequency: Hourly (Run ~5-10 minutes after hourly data import completes).
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
_module_name TEXT := 'daily_snapshots';
|
_module_name TEXT := 'daily_snapshots';
|
||||||
_start_time TIMESTAMPTZ := clock_timestamp(); -- Time execution started
|
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||||
_last_calc_time TIMESTAMPTZ;
|
_target_date DATE;
|
||||||
_target_date DATE; -- Will be set in the loop
|
|
||||||
_total_records INT := 0;
|
_total_records INT := 0;
|
||||||
_has_orders BOOLEAN := FALSE;
|
_days_processed INT := 0;
|
||||||
_process_days INT := 5; -- Number of days to check/process (today plus previous 4 days)
|
_max_backfill_days INT := 90; -- Safety cap: max days to backfill per run
|
||||||
_day_counter INT;
|
_recent_recheck_days INT := 2; -- Always reprocess this many recent days (today + yesterday)
|
||||||
_missing_days INT[] := ARRAY[]::INT[]; -- Array to store days with missing or incomplete data
|
_latest_snapshot DATE;
|
||||||
|
_backfill_start DATE;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get the timestamp before the last successful run of this module
|
|
||||||
SELECT last_calculation_timestamp INTO _last_calc_time
|
|
||||||
FROM public.calculate_status
|
|
||||||
WHERE module_name = _module_name;
|
|
||||||
|
|
||||||
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
|
RAISE NOTICE 'Running % script. Start Time: %', _module_name, _start_time;
|
||||||
|
|
||||||
-- First, check which days need processing by comparing orders data with snapshot data
|
-- Find the latest existing snapshot date (for logging only)
|
||||||
FOR _day_counter IN 0..(_process_days-1) LOOP
|
SELECT MAX(snapshot_date) INTO _latest_snapshot
|
||||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
FROM public.daily_product_snapshots;
|
||||||
|
|
||||||
-- Check if this date needs updating by comparing orders to snapshot data
|
-- Always scan the full backfill window to catch holes in the middle,
|
||||||
-- If the date has orders but not enough snapshots, or if snapshots show zero sales but orders exist, it's incomplete
|
-- not just gaps at the end. The gap fill and stale detection queries
|
||||||
SELECT
|
-- need to see the entire range to find missing or outdated snapshots.
|
||||||
CASE WHEN (
|
_backfill_start := CURRENT_DATE - _max_backfill_days;
|
||||||
-- We have orders for this date but not enough snapshots, or snapshots with wrong total
|
|
||||||
(EXISTS (SELECT 1 FROM public.orders WHERE date::date = _target_date) AND
|
|
||||||
(
|
|
||||||
-- No snapshots exist for this date
|
|
||||||
NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) OR
|
|
||||||
-- Or snapshots show zero sales but orders exist
|
|
||||||
(SELECT COALESCE(SUM(units_sold), 0) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) = 0 OR
|
|
||||||
-- Or the count of snapshot records is significantly less than distinct products in orders
|
|
||||||
(SELECT COUNT(*) FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) <
|
|
||||||
(SELECT COUNT(DISTINCT pid) FROM public.orders WHERE date::date = _target_date) * 0.8
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) THEN TRUE ELSE FALSE END
|
|
||||||
INTO _has_orders;
|
|
||||||
|
|
||||||
IF _has_orders THEN
|
IF _latest_snapshot IS NULL THEN
|
||||||
-- This day needs processing - add to our array
|
RAISE NOTICE 'No existing snapshots found. Backfilling up to % days.', _max_backfill_days;
|
||||||
_missing_days := _missing_days || _day_counter;
|
ELSE
|
||||||
RAISE NOTICE 'Day % needs updating (incomplete or missing data)', _target_date;
|
RAISE NOTICE 'Latest snapshot: %. Scanning from % for gaps and stale data.', _latest_snapshot, _backfill_start;
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
-- If no days need updating, exit early
|
|
||||||
IF array_length(_missing_days, 1) IS NULL THEN
|
|
||||||
RAISE NOTICE 'No days need updating - all snapshot data appears complete';
|
|
||||||
|
|
||||||
-- Still update the calculate_status to record this run
|
|
||||||
UPDATE public.calculate_status
|
|
||||||
SET last_calculation_timestamp = _start_time
|
|
||||||
WHERE module_name = _module_name;
|
|
||||||
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
RAISE NOTICE 'Need to update % days with missing or incomplete data', array_length(_missing_days, 1);
|
-- Process all dates that need snapshots:
|
||||||
|
-- 1. Gap fill: dates with orders/receivings but no snapshots (older than recent window)
|
||||||
|
-- 2. Stale detection: existing snapshots where aggregates don't match source data
|
||||||
|
-- (catches backfilled imports that arrived after snapshot was calculated)
|
||||||
|
-- 3. Recent recheck: last N days always reprocessed (picks up new orders, corrections)
|
||||||
|
FOR _target_date IN
|
||||||
|
SELECT d FROM (
|
||||||
|
-- Gap fill: find dates with activity but missing snapshots
|
||||||
|
SELECT activity_dates.d
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT date::date AS d FROM public.orders
|
||||||
|
WHERE date::date >= _backfill_start AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT received_date::date AS d FROM public.receivings
|
||||||
|
WHERE received_date::date >= _backfill_start AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||||
|
) activity_dates
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.daily_product_snapshots dps WHERE dps.snapshot_date = activity_dates.d
|
||||||
|
)
|
||||||
|
UNION
|
||||||
|
-- Stale detection: compare snapshot aggregates against source tables
|
||||||
|
SELECT snap_agg.snapshot_date AS d
|
||||||
|
FROM (
|
||||||
|
SELECT snapshot_date,
|
||||||
|
COALESCE(SUM(units_received), 0)::bigint AS snap_received,
|
||||||
|
COALESCE(SUM(units_sold), 0)::bigint AS snap_sold
|
||||||
|
FROM public.daily_product_snapshots
|
||||||
|
WHERE snapshot_date >= _backfill_start
|
||||||
|
AND snapshot_date < CURRENT_DATE - _recent_recheck_days
|
||||||
|
GROUP BY snapshot_date
|
||||||
|
) snap_agg
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT received_date::date AS d, SUM(qty_each)::bigint AS actual_received
|
||||||
|
FROM public.receivings
|
||||||
|
WHERE received_date::date >= _backfill_start
|
||||||
|
AND received_date::date < CURRENT_DATE - _recent_recheck_days
|
||||||
|
GROUP BY received_date::date
|
||||||
|
) recv_agg ON snap_agg.snapshot_date = recv_agg.d
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT date::date AS d,
|
||||||
|
SUM(CASE WHEN quantity > 0 AND COALESCE(status, 'pending') NOT IN ('canceled', 'returned')
|
||||||
|
THEN quantity ELSE 0 END)::bigint AS actual_sold
|
||||||
|
FROM public.orders
|
||||||
|
WHERE date::date >= _backfill_start
|
||||||
|
AND date::date < CURRENT_DATE - _recent_recheck_days
|
||||||
|
GROUP BY date::date
|
||||||
|
) orders_agg ON snap_agg.snapshot_date = orders_agg.d
|
||||||
|
WHERE snap_agg.snap_received != COALESCE(recv_agg.actual_received, 0)
|
||||||
|
OR snap_agg.snap_sold != COALESCE(orders_agg.actual_sold, 0)
|
||||||
|
UNION
|
||||||
|
-- Recent days: always reprocess
|
||||||
|
SELECT d::date
|
||||||
|
FROM generate_series(
|
||||||
|
(CURRENT_DATE - _recent_recheck_days)::timestamp,
|
||||||
|
CURRENT_DATE::timestamp,
|
||||||
|
'1 day'::interval
|
||||||
|
) d
|
||||||
|
) dates_to_process
|
||||||
|
ORDER BY d
|
||||||
|
LOOP
|
||||||
|
_days_processed := _days_processed + 1;
|
||||||
|
|
||||||
-- Process only the days that need updating
|
-- Classify why this date is being processed (for logging)
|
||||||
FOREACH _day_counter IN ARRAY _missing_days LOOP
|
IF _target_date >= CURRENT_DATE - _recent_recheck_days THEN
|
||||||
_target_date := CURRENT_DATE - (_day_counter * INTERVAL '1 day');
|
RAISE NOTICE 'Processing date: % [recent recheck]', _target_date;
|
||||||
RAISE NOTICE 'Processing date: %', _target_date;
|
ELSIF NOT EXISTS (SELECT 1 FROM public.daily_product_snapshots WHERE snapshot_date = _target_date) THEN
|
||||||
|
RAISE NOTICE 'Processing date: % [gap fill — no existing snapshot]', _target_date;
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Processing date: % [stale data — snapshot aggregates mismatch source]', _target_date;
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- IMPORTANT: First delete any existing data for this date to prevent duplication
|
-- IMPORTANT: First delete any existing data for this date to prevent duplication
|
||||||
DELETE FROM public.daily_product_snapshots
|
DELETE FROM public.daily_product_snapshots
|
||||||
@@ -90,7 +124,6 @@ BEGIN
|
|||||||
COALESCE(
|
COALESCE(
|
||||||
o.costeach, -- First use order-specific cost if available
|
o.costeach, -- First use order-specific cost if available
|
||||||
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
|
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
|
||||||
p.landing_cost_price, -- Fallback to landing cost
|
|
||||||
p.cost_price -- Final fallback to current cost
|
p.cost_price -- Final fallback to current cost
|
||||||
) * o.quantity
|
) * o.quantity
|
||||||
ELSE 0 END), 0.00) AS cogs,
|
ELSE 0 END), 0.00) AS cogs,
|
||||||
@@ -124,14 +157,16 @@ BEGIN
|
|||||||
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||||
),
|
),
|
||||||
CurrentStock AS (
|
CurrentStock AS (
|
||||||
-- Select current stock values directly from products table
|
-- Use historical stock from stock_snapshots when available,
|
||||||
|
-- falling back to current stock from products table
|
||||||
SELECT
|
SELECT
|
||||||
pid,
|
p.pid,
|
||||||
stock_quantity,
|
COALESCE(ss.stock_quantity, p.stock_quantity) AS stock_quantity,
|
||||||
COALESCE(landing_cost_price, cost_price, 0.00) as effective_cost_price,
|
COALESCE(ss.stock_value, p.stock_quantity * COALESCE(p.cost_price, 0.00)) AS stock_value,
|
||||||
COALESCE(price, 0.00) as current_price,
|
COALESCE(p.price, 0.00) AS current_price,
|
||||||
COALESCE(regular_price, 0.00) as current_regular_price
|
COALESCE(p.regular_price, 0.00) AS current_regular_price
|
||||||
FROM public.products
|
FROM public.products p
|
||||||
|
LEFT JOIN stock_snapshots ss ON p.pid = ss.pid AND ss.snapshot_date = _target_date
|
||||||
),
|
),
|
||||||
ProductsWithActivity AS (
|
ProductsWithActivity AS (
|
||||||
-- Quick pre-filter to only process products with activity
|
-- Quick pre-filter to only process products with activity
|
||||||
@@ -171,7 +206,7 @@ BEGIN
|
|||||||
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
|
COALESCE(sd.sku, p.sku) AS sku, -- Get SKU from sales data or products table
|
||||||
-- Inventory Metrics (Using CurrentStock)
|
-- Inventory Metrics (Using CurrentStock)
|
||||||
cs.stock_quantity AS eod_stock_quantity,
|
cs.stock_quantity AS eod_stock_quantity,
|
||||||
cs.stock_quantity * cs.effective_cost_price AS eod_stock_cost,
|
cs.stock_value AS eod_stock_cost,
|
||||||
cs.stock_quantity * cs.current_price AS eod_stock_retail,
|
cs.stock_quantity * cs.current_price AS eod_stock_retail,
|
||||||
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
|
cs.stock_quantity * cs.current_regular_price AS eod_stock_gross,
|
||||||
(cs.stock_quantity <= 0) AS stockout_flag,
|
(cs.stock_quantity <= 0) AS stockout_flag,
|
||||||
@@ -181,10 +216,10 @@ BEGIN
|
|||||||
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||||
COALESCE(sd.discounts, 0.00),
|
COALESCE(sd.discounts, 0.00),
|
||||||
COALESCE(sd.returns_revenue, 0.00),
|
COALESCE(sd.returns_revenue, 0.00),
|
||||||
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00) AS net_revenue,
|
||||||
COALESCE(sd.cogs, 0.00),
|
COALESCE(sd.cogs, 0.00),
|
||||||
COALESCE(sd.gross_regular_revenue, 0.00),
|
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||||
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit, -- Basic profit: Net Revenue - COGS
|
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) - COALESCE(sd.returns_revenue, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||||
-- Receiving Metrics (From ReceivingData)
|
-- Receiving Metrics (From ReceivingData)
|
||||||
COALESCE(rd.units_received, 0),
|
COALESCE(rd.units_received, 0),
|
||||||
COALESCE(rd.cost_received, 0.00),
|
COALESCE(rd.cost_received, 0.00),
|
||||||
@@ -201,12 +236,18 @@ BEGIN
|
|||||||
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
|
RAISE NOTICE 'Created % daily snapshot records for % with sales/receiving activity', _total_records, _target_date;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
-- Update the status table with the timestamp from the START of this run
|
IF _days_processed = 0 THEN
|
||||||
UPDATE public.calculate_status
|
RAISE NOTICE 'No days need updating — all snapshot data is current.';
|
||||||
SET last_calculation_timestamp = _start_time
|
ELSE
|
||||||
WHERE module_name = _module_name;
|
RAISE NOTICE 'Processed % days total.', _days_processed;
|
||||||
|
END IF;
|
||||||
|
|
||||||
RAISE NOTICE 'Finished % processing for multiple dates. Duration: %', _module_name, clock_timestamp() - _start_time;
|
-- Update the status table with the timestamp from the START of this run
|
||||||
|
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||||
|
VALUES (_module_name, _start_time)
|
||||||
|
ON CONFLICT (module_name) DO UPDATE SET last_calculation_timestamp = _start_time;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Finished % script. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||||
|
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
|
||||||
|
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
|
||||||
|
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
|
||||||
|
-- Frequency: After each metrics run and/or after forecast engine runs.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
_module_name TEXT := 'lifecycle_forecasts';
|
||||||
|
_start_time TIMESTAMPTZ := clock_timestamp();
|
||||||
|
_updated INT;
|
||||||
|
BEGIN
|
||||||
|
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||||
|
|
||||||
|
-- Step 1: Set lifecycle_phase from product_forecasts (one phase per product)
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
SET lifecycle_phase = sub.lifecycle_phase
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (pid) pid, lifecycle_phase
|
||||||
|
FROM product_forecasts
|
||||||
|
ORDER BY pid, forecast_date
|
||||||
|
) sub
|
||||||
|
WHERE pm.pid = sub.pid
|
||||||
|
AND (pm.lifecycle_phase IS DISTINCT FROM sub.lifecycle_phase);
|
||||||
|
|
||||||
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Updated lifecycle_phase for % products', _updated;
|
||||||
|
|
||||||
|
-- Step 2: Compute lifecycle-based lead time and planning period forecasts
|
||||||
|
-- Uses each product's configured lead time and days of stock
|
||||||
|
WITH forecast_sums AS (
|
||||||
|
SELECT
|
||||||
|
pf.pid,
|
||||||
|
SUM(pf.forecast_units) FILTER (
|
||||||
|
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time
|
||||||
|
) AS lt_forecast,
|
||||||
|
SUM(pf.forecast_units) FILTER (
|
||||||
|
WHERE pf.forecast_date <= CURRENT_DATE + s.effective_lead_time + s.effective_days_of_stock
|
||||||
|
) AS pp_forecast
|
||||||
|
FROM product_forecasts pf
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
COALESCE(sp.lead_time_days, sv.default_lead_time_days,
|
||||||
|
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_lead_time_days'), 14
|
||||||
|
) AS effective_lead_time,
|
||||||
|
COALESCE(sp.days_of_stock, sv.default_days_of_stock,
|
||||||
|
(SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_days_of_stock'), 30
|
||||||
|
) AS effective_days_of_stock
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN settings_product sp ON p.pid = sp.pid
|
||||||
|
LEFT JOIN settings_vendor sv ON p.vendor = sv.vendor
|
||||||
|
) s ON s.pid = pf.pid
|
||||||
|
WHERE pf.forecast_date >= CURRENT_DATE
|
||||||
|
GROUP BY pf.pid
|
||||||
|
)
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
SET
|
||||||
|
lifecycle_lead_time_forecast = COALESCE(fs.lt_forecast, 0),
|
||||||
|
lifecycle_planning_period_forecast = COALESCE(fs.pp_forecast, 0)
|
||||||
|
FROM forecast_sums fs
|
||||||
|
WHERE pm.pid = fs.pid
|
||||||
|
AND (pm.lifecycle_lead_time_forecast IS DISTINCT FROM COALESCE(fs.lt_forecast, 0)
|
||||||
|
OR pm.lifecycle_planning_period_forecast IS DISTINCT FROM COALESCE(fs.pp_forecast, 0));
|
||||||
|
|
||||||
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Updated lifecycle forecasts for % products', _updated;
|
||||||
|
|
||||||
|
-- Step 3: Reclassify demand_pattern using residual CV (de-trended)
|
||||||
|
-- For launch/decay products, raw CV is high because of expected lifecycle decay.
|
||||||
|
-- We subtract the expected brand curve value to get residuals, then compute CV on those.
|
||||||
|
-- Products that track their brand curve closely → low residual CV → "stable"
|
||||||
|
-- Products with erratic deviations from curve → higher residual CV → "variable"/"sporadic"
|
||||||
|
WITH product_curve AS (
|
||||||
|
-- Get each product's brand curve and age
|
||||||
|
SELECT
|
||||||
|
pm.pid,
|
||||||
|
pm.lifecycle_phase,
|
||||||
|
pm.date_first_received,
|
||||||
|
blc.amplitude,
|
||||||
|
blc.decay_rate,
|
||||||
|
blc.baseline
|
||||||
|
FROM product_metrics pm
|
||||||
|
JOIN products p ON p.pid = pm.pid
|
||||||
|
LEFT JOIN brand_lifecycle_curves blc
|
||||||
|
ON blc.brand = pm.brand
|
||||||
|
AND blc.root_category IS NULL -- brand-only curve
|
||||||
|
WHERE pm.lifecycle_phase IN ('launch', 'decay')
|
||||||
|
AND pm.date_first_received IS NOT NULL
|
||||||
|
AND blc.amplitude IS NOT NULL
|
||||||
|
),
|
||||||
|
daily_residuals AS (
|
||||||
|
-- Compute residual = actual - expected for each snapshot day
|
||||||
|
-- Curve params are in WEEKLY units; divide by 7 to get daily expected
|
||||||
|
SELECT
|
||||||
|
dps.pid,
|
||||||
|
dps.units_sold,
|
||||||
|
(pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS expected,
|
||||||
|
dps.units_sold - (pc.amplitude * EXP(-pc.decay_rate * (dps.snapshot_date - pc.date_first_received)::numeric / 7.0) + pc.baseline) / 7.0 AS residual
|
||||||
|
FROM daily_product_snapshots dps
|
||||||
|
JOIN product_curve pc ON pc.pid = dps.pid
|
||||||
|
WHERE dps.snapshot_date >= CURRENT_DATE - INTERVAL '29 days'
|
||||||
|
AND dps.snapshot_date <= CURRENT_DATE
|
||||||
|
),
|
||||||
|
residual_cv AS (
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
AVG(units_sold) AS avg_sales,
|
||||||
|
CASE WHEN COUNT(*) >= 7 AND AVG(ABS(expected)) > 0.01 THEN
|
||||||
|
STDDEV_POP(residual) / GREATEST(AVG(ABS(expected)), 0.1)
|
||||||
|
END AS res_cv
|
||||||
|
FROM daily_residuals
|
||||||
|
GROUP BY pid
|
||||||
|
)
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
SET demand_pattern = classify_demand_pattern(rc.avg_sales, rc.res_cv)
|
||||||
|
FROM residual_cv rc
|
||||||
|
WHERE pm.pid = rc.pid
|
||||||
|
AND rc.res_cv IS NOT NULL
|
||||||
|
AND pm.demand_pattern IS DISTINCT FROM classify_demand_pattern(rc.avg_sales, rc.res_cv);
|
||||||
|
|
||||||
|
GET DIAGNOSTICS _updated = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'Reclassified demand_pattern for % launch/decay products', _updated;
|
||||||
|
|
||||||
|
-- Update tracking
|
||||||
|
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||||
|
VALUES (_module_name, clock_timestamp())
|
||||||
|
ON CONFLICT (module_name) DO UPDATE SET
|
||||||
|
last_calculation_timestamp = EXCLUDED.last_calculation_timestamp;
|
||||||
|
|
||||||
|
RAISE NOTICE '% module complete. Duration: %', _module_name, clock_timestamp() - _start_time;
|
||||||
|
END $$;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user