diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..076abce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development +npm run dev # Start with nodemon auto-reload +npm start # Production start + +# Testing +npm test # Run all tests once (vitest run) +npm run test:watch # Watch mode +npm run test:coverage + +# Run a single test file +npx vitest run src/modules/3-turn-engine/orderModel.test.js + +# Database migrations (requires DATABASE_URL in .env) +npm run migrate:up +npm run migrate:down +npm run migrate:redo +npm run migrate:status +npm run seed # Seed a tenant via scripts/seed-tenant.mjs +``` + +No lint command is configured. + +## Architecture + +This is a **multi-tenant WhatsApp e-commerce chatbot** powered by Express.js. Tenants are WooCommerce store operators; their customers interact via WhatsApp to browse products, build carts, and place orders. All database operations are isolated by `tenant_id`. + +### Request flow + +``` +WhatsApp → Evolution API webhook → /webhook/evolution + ↓ + 1-intake: route & normalize message + ↓ + 3-turn-engine: NLU → FSM → state handler + ↓ + Response persisted to DB + sent back via Evolution API +``` + +### Module structure (numbered layers) + +- **`src/modules/0-UI/`** — Admin dashboard: REST controllers for products, conversations, settings, prompts, takeovers, recommendations, aliases. Each controller has a `db/` sub-layer for persistence. + +- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp). Normalizes incoming messages before passing to turn engine. + +- **`src/modules/2-identity/`** — Tenant and user management. Maps WhatsApp numbers to WooCommerce customers. Stores encrypted WooCommerce credentials per tenant in `tenant_ecommerce_config`. Routes WooCommerce webhooks. + +- **`src/modules/3-turn-engine/`** — Core logic. NLU classifies intents; FSM transitions states (`IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS`). Two NLU versions controlled by `USE_MODULAR_NLU` env flag. Two turn engine versions controlled by `TURN_ENGINE` env flag. State handlers map to FSM states. + +- **`src/modules/4-woo-orders/`** — WooCommerce order sync. Fetches and caches customer order history for conversation context. + +- **`src/modules/shared/`** — DB pool (PostgreSQL via `pg`), SSE for real-time admin UI updates, WooSnapshot (product catalog cache), debug utilities. + +### Key integrations + +| System | Purpose | Config | +|--------|---------|--------| +| OpenAI | NLU intent classification & response generation | `OPENAI_API_KEY`, `OPENAI_MODEL` | +| Evolution API | WhatsApp send/receive | `EVOLUTION_API_URL`, `EVOLUTION_API_KEY`, `EVOLUTION_INSTANCE_NAME`, `EVOLUTION_SEND_ENABLED` | +| WooCommerce REST API | Products, orders, customers | `WOO_*` env vars or per-tenant in DB | +| PostgreSQL | Primary database | `DATABASE_URL` | + +### Database + +Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables: +- `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels` +- `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping +- `wa_conversation_state` — FSM state + context per conversation +- `wa_messages` — Message history +- `woo_products_snapshot` — Cached product catalog +- `prompt_templates` — Versioned LLM prompts +- `human_takeovers`, `audit_log`, `conversation_runs` + +### Feature flags (env vars) + +- `TURN_ENGINE=v1|v2` — Which turn engine version to use +- `USE_MODULAR_NLU=1` — Use modular NLU (prompt templates from DB) vs. v3 hardcoded +- `EVOLUTION_SEND_ENABLED=1` — Actually send messages to WhatsApp (disable in dev/test) +- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — Granular debug logging + +### Local development + +Copy `env.example` to `.env` and fill in values. Use `docker-compose.override.yaml` for local overrides. Run `docker compose up` to start app + Postgres + Redis. The Dockerfile runs migrations automatically on startup (`migrate:up && seed && start`). + +Test files use Vitest with `globals: true` — no need to import `describe`, `it`, `expect`. diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml index a99fa9b..a447f01 100644 --- a/docker-compose.override.yaml +++ b/docker-compose.override.yaml @@ -4,7 +4,7 @@ services: app: ports: - - "3000:3000" + - "3001:3000" env_file: - .env environment: @@ -13,3 +13,5 @@ services: volumes: - .:/usr/src/app - /usr/src/app/node_modules + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/env.example b/env.example index 970376b..29f49f6 100644 --- a/env.example +++ b/env.example @@ -42,6 +42,12 @@ EVOLUTION_API_KEY=your-api-key EVOLUTION_INSTANCE_NAME=piaf EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción) +# =================== +# Limits +# =================== +LIMIT_CONVERSATIONS=100 +MAX_CHARS_PER_MESSAGE=4000 + # =================== # Debug Flags (1/true/yes/on para activar) # =================== diff --git a/package-lock.json b/package-lock.json index ee666ed..e9ffbc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "ajv": "^8.17.1", "cors": "^2.8.5", "csv-parse": "^6.1.0", + "dbmate": "^2.0.0", "dotenv": "^17.2.3", "express": "^4.19.2", "mysql2": "^3.16.2", @@ -22,7 +23,6 @@ }, "devDependencies": { "@vitest/coverage-v8": "^4.0.18", - "dbmate": "^2.0.0", "nodemon": "^3.0.3", "vitest": "^4.0.18" } @@ -94,7 +94,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -108,7 +107,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -122,7 +120,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -136,7 +133,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -150,7 +146,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -164,7 +159,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -178,7 +172,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1466,7 +1459,6 @@ "version": "2.28.0", "resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz", "integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==", - "dev": true, "license": "MIT", "bin": { "dbmate": "dist/cli.js" diff --git a/src/modules/0-ui/handlers/stats.js b/src/modules/0-ui/handlers/stats.js index 359864a..ac8b60d 100644 --- a/src/modules/0-ui/handlers/stats.js +++ b/src/modules/0-ui/handlers/stats.js @@ -5,10 +5,12 @@ import * as ordersRepo from "../../4-woo-orders/ordersRepo.js"; * Obtiene estadísticas de pedidos para el dashboard */ export async function handleGetOrderStats({ tenantId }) { - // 1. Sincronizar pedidos nuevos de Woo - const syncResult = await syncOrdersIncremental({ tenantId }); - - // 2. Obtener todas las estadísticas en paralelo + // Sync en background — no bloqueamos el request + const syncPromise = syncOrdersIncremental({ tenantId }).catch(err => + console.error("[stats] sync error:", err) + ); + + // Respondemos con lo que hay en DB mientras sincroniza const [monthlyStats, productStats, yoyStats, totals] = await Promise.all([ ordersRepo.getMonthlyStats({ tenantId }), ordersRepo.getProductStats({ tenantId }), @@ -36,8 +38,8 @@ export async function handleGetOrderStats({ tenantId }) { // YoY yoy: yoyStats, - // Info de sync - synced: syncResult.synced, - total_in_cache: syncResult.total, + // Info de sync (sincronizando en background) + synced: 0, + total_in_cache: totals.total_orders ?? 0, }; } diff --git a/src/modules/3-turn-engine/nlu/specialists/browse.js b/src/modules/3-turn-engine/nlu/specialists/browse.js index d8e9f0d..c482689 100644 --- a/src/modules/3-turn-engine/nlu/specialists/browse.js +++ b/src/modules/3-turn-engine/nlu/specialists/browse.js @@ -14,7 +14,8 @@ function getClient() { throw new Error("OPENAI_API_KEY is not set"); } if (!_client) { - _client = new OpenAI({ apiKey }); + const baseURL = process.env.OPENAI_BASE_URL || undefined; + _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); } return _client; } diff --git a/src/modules/3-turn-engine/nlu/specialists/orders.js b/src/modules/3-turn-engine/nlu/specialists/orders.js index 4905f89..0f1da5d 100644 --- a/src/modules/3-turn-engine/nlu/specialists/orders.js +++ b/src/modules/3-turn-engine/nlu/specialists/orders.js @@ -17,7 +17,8 @@ function getClient() { throw new Error("OPENAI_API_KEY is not set"); } if (!_client) { - _client = new OpenAI({ apiKey }); + const baseURL = process.env.OPENAI_BASE_URL || undefined; + _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); } return _client; } diff --git a/src/modules/3-turn-engine/openai.js b/src/modules/3-turn-engine/openai.js index 065ee41..def865e 100644 --- a/src/modules/3-turn-engine/openai.js +++ b/src/modules/3-turn-engine/openai.js @@ -18,7 +18,8 @@ function getClient() { } if (_client && _clientKey === apiKey) return _client; _clientKey = apiKey; - _client = new OpenAI({ apiKey }); + const baseURL = process.env.OPENAI_BASE_URL || undefined; + _client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) }); return _client; }