diff --git a/db/migrations/20260102021529_wa_identity_map.sql b/db/migrations/20260102021529_wa_identity_map.sql new file mode 100644 index 0000000..10e7c4a --- /dev/null +++ b/db/migrations/20260102021529_wa_identity_map.sql @@ -0,0 +1,12 @@ +-- migrate:up +create table wa_identity_map ( + tenant_id uuid not null references tenants(id) on delete cascade, + wa_chat_id text not null, + woo_customer_id bigint not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (tenant_id, wa_chat_id) +); + +-- migrate:down +drop table if exists wa_identity_map; diff --git a/db/migrations/20260102021640_tenants.sql b/db/migrations/20260102021640_tenants.sql new file mode 100644 index 0000000..469b00d --- /dev/null +++ b/db/migrations/20260102021640_tenants.sql @@ -0,0 +1,13 @@ +-- migrate:up +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE tenants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + key text NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- migrate:down +DROP TABLE IF EXISTS tenants; +DROP EXTENSION IF EXISTS pgcrypto; \ No newline at end of file diff --git a/db/migrations/20260102021749_wa_conversation_state.sql b/db/migrations/20260102021749_wa_conversation_state.sql new file mode 100644 index 0000000..4754f21 --- /dev/null +++ b/db/migrations/20260102021749_wa_conversation_state.sql @@ -0,0 +1,23 @@ +-- migrate:up +create table wa_conversation_state ( + tenant_id uuid not null references tenants(id) on delete cascade, + wa_chat_id text not null, + + state text not null, -- IDLE / BUILDING_ORDER / WAITING_PAYMENT + last_intent text null, + last_order_id bigint null, + + context jsonb not null default '{}'::jsonb, + + state_updated_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + primary key (tenant_id, wa_chat_id) +); + +create index idx_state_tenant_updated + on wa_conversation_state (tenant_id, updated_at desc); + +-- migrate:down +drop table if exists wa_conversation_state; diff --git a/db/migrations/20260102021829_wa_messages.sql b/db/migrations/20260102021829_wa_messages.sql new file mode 100644 index 0000000..80c82fe --- /dev/null +++ b/db/migrations/20260102021829_wa_messages.sql @@ -0,0 +1,24 @@ +-- migrate:up +create table wa_messages ( + id bigserial primary key, + tenant_id uuid not null references tenants(id) on delete cascade, + wa_chat_id text not null, + + provider text not null, -- sim / evolution / twilio + message_id text not null, -- idempotencia por provider + direction text not null, -- in / out + + ts timestamptz not null default now(), + text text null, + payload jsonb not null default '{}'::jsonb, + + run_id uuid null, + + unique (tenant_id, provider, message_id) +); + +create index idx_msgs_tenant_chat_ts + on wa_messages (tenant_id, wa_chat_id, ts desc); + +-- migrate:down +drop table if exists wa_messages; diff --git a/db/migrations/20260102021907_conversation_runs.sql b/db/migrations/20260102021907_conversation_runs.sql new file mode 100644 index 0000000..61a3017 --- /dev/null +++ b/db/migrations/20260102021907_conversation_runs.sql @@ -0,0 +1,32 @@ +-- migrate:up +create table conversation_runs ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references tenants(id) on delete cascade, + wa_chat_id text not null, + message_id text not null, + + ts timestamptz not null default now(), + prev_state text null, + user_text text null, + + llm_output jsonb null, + tools jsonb not null default '[]'::jsonb, + invariants jsonb not null default '{}'::jsonb, + + final_reply text null, + order_id bigint null, + payment_link text null, + + status text not null default 'ok', -- ok | warn | error + error_code text null, + error_detail text null, + latency_ms int null, + + unique (tenant_id, message_id) +); + +create index idx_runs_tenant_chat_ts + on conversation_runs (tenant_id, wa_chat_id, ts desc); + +-- migrate:down +drop table if exists conversation_runs; diff --git a/docker-compose.yaml b/docker-compose.yaml index fdb8131..d2514ed 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,5 @@ +version: "3.9" + services: app: image: node:20-alpine @@ -5,7 +7,10 @@ services: command: sh -c "npm install && npm run dev" ports: - "3000:3000" + env_file: + - .env environment: + - NODE_ENV=development - PORT=3000 - DATABASE_URL=postgres://${POSTGRES_USER:-botino}:${POSTGRES_PASSWORD:-botino}@db:5432/${POSTGRES_DB:-botino} - REDIS_URL=redis://redis:6379 @@ -13,12 +18,16 @@ services: - .:/usr/src/app - /usr/src/app/node_modules depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped db: image: postgres:16-alpine + env_file: + - .env environment: - POSTGRES_DB=${POSTGRES_DB:-botino} - POSTGRES_USER=${POSTGRES_USER:-botino} diff --git a/package-lock.json b/package-lock.json index 5b9c84f..76e6823 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "cors": "^2.8.5", - "express": "^4.19.2" + "express": "^4.19.2", + "openai": "^6.15.0", + "zod": "^4.3.4" }, "devDependencies": { "dbmate": "^2.0.0", @@ -965,6 +967,27 @@ "node": ">= 0.8" } }, + "node_modules/openai": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz", + "integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1340,6 +1363,15 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/zod": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", + "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 6b19ca0..61cacba 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "license": "MIT", "dependencies": { "cors": "^2.8.5", - "express": "^4.19.2" + "express": "^4.19.2", + "openai": "^6.15.0", + "zod": "^4.3.4" }, "devDependencies": { "dbmate": "^2.0.0", diff --git a/src/services/openai.js b/src/services/openai.js new file mode 100644 index 0000000..b9f4f41 --- /dev/null +++ b/src/services/openai.js @@ -0,0 +1,21 @@ +// src/services/openai.js (o directo en main.js por ahora) +import OpenAI from "openai"; + +export const openai = new OpenAI({ apiKey: process.env.OPENAI_APIKEY }); + +// promptSystem = tu prompt (no lo tocamos mucho) +// input = { last_user_message, conversation_history, current_conversation_state, ... } +export async function llmPlan({ promptSystem, input }) { + const resp = await openai.responses.create({ + model: "gpt-5-mini", // o gpt-5 (más caro/mejor) / el que estés usando + input: [ + { role: "system", content: promptSystem }, + { role: "user", content: JSON.stringify(input) } + ], + // Si estás usando "Structured Outputs" nativo, acá va tu schema. + // En caso de que tu SDK no lo soporte directo, lo hacemos con zod/JSON parse robusto. + }); + + const text = resp.output_text; // ojo: depende del SDK/model; es el agregado de outputs + return text; +}