Quellcode durchsuchen

Rewrite EKSRelay as a Node.js/TypeScript service

Replaces the PHP relay with a containerised Node 22 / TypeScript service while
keeping every /tools/* contract byte-compatible with the deployed Flowise
custom tools.

What this fixes from the PHP version:
- Flowise auth sent a literal 'Bearer ***' placeholder; the real API key is
  now used, and only when one is configured.
- Webhook retries could answer a customer twice; ProcessedMessage is now
  unique on (source, messageId), with a content-hash surrogate id for
  payloads that carry none.
- The webhook waited synchronously for Flowise; it now persists a job,
  returns 202, and a worker processes it with retry and backoff.
- No spam filtering; a rule-based gate now sits in front of Flowise.
- Ticket number was the bare conversation id; it is now
  EKS-YYYYMMDD-<conversationId>, idempotent per conversation.
- unassignConversation() existed but was never called; it now runs behind
  CHATWOOT_UNASSIGN_ON_TICKET.
- Request bodies with PII were written to a log file; logging is now
  structured JSON with secrets always redacted and PII behind LOG_PII.

Adds: /health and /ready, /admin/* behind a separate token, Prisma/SQLite
persistence with migrations, Zod-validated config, Docker Compose with Traefik
labels, and 86 tests.

The PHP implementation is preserved at the php-legacy tag and stays deployed
as a fallback; see docs/MIGRATION.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WvzjhjUY7tr5Naj1Co4N6B
Maciek vor 4 Wochen
Ursprung
Commit
7fe0ea23e8
84 geänderte Dateien mit 7102 neuen und 2136 gelöschten Zeilen
  1. 58 26
      .env.example
  2. 18 9
      .gitignore
  3. 29 0
      Dockerfile
  4. 148 86
      README.md
  5. 0 13
      composer.json
  6. 34 0
      docker-compose.studio.yml
  7. 48 0
      docker-compose.yml
  8. 50 0
      docker/entrypoint.sh
  9. 170 0
      docs/DEPLOY.md
  10. 74 0
      docs/MIGRATION.md
  11. 156 0
      docs/SMOKE_TEST.md
  12. 23 0
      flowise-tools/README.md
  13. 3 5
      flowise-tools/get_order_data.json
  14. 6 5
      flowise-tools/get_payment_methods.json
  15. 0 2
      flowise-tools/get_product_compatibility.json
  16. 1 1
      flowise-tools/get_product_data.json
  17. 1 1
      flowise-tools/get_shipping_data.json
  18. 6 5
      flowise-tools/new_ticket.json
  19. 1946 0
      package-lock.json
  20. 35 0
      package.json
  21. 71 0
      prisma/migrations/20260820115241_init/migration.sql
  22. 3 0
      prisma/migrations/migration_lock.toml
  23. 80 0
      prisma/schema.prisma
  24. 0 66
      public/index.php
  25. 50 0
      scripts/events.ts
  26. 0 193
      src/Clients/ChatwootClient.php
  27. 0 88
      src/Clients/FlowiseClient.php
  28. 0 298
      src/Clients/WooCommerceClient.php
  29. 0 27
      src/Core/Auth.php
  30. 0 71
      src/Core/Env.php
  31. 0 90
      src/Core/HttpClient.php
  32. 0 30
      src/Core/HttpException.php
  33. 0 172
      src/Core/Logger.php
  34. 0 78
      src/Core/Router.php
  35. 0 121
      src/Core/StoreLocales.php
  36. 0 190
      src/Handlers/ChatwootWebhookHandler.php
  37. 0 104
      src/Handlers/NewTicketHandler.php
  38. 0 455
      src/Handlers/WooToolsHandler.php
  39. 119 0
      src/clients/chatwootClient.ts
  40. 111 0
      src/clients/flowiseClient.ts
  41. 79 0
      src/clients/httpClient.ts
  42. 271 0
      src/clients/wooClient.ts
  43. 94 0
      src/clients/wpStoreClient.ts
  44. 100 0
      src/config.ts
  45. 188 0
      src/domain/conversationPipeline.ts
  46. 98 0
      src/domain/formatters.ts
  47. 157 0
      src/domain/messageNormalizer.ts
  48. 102 0
      src/domain/spamGate.ts
  49. 109 0
      src/domain/storeLocales.ts
  50. 128 0
      src/domain/ticketService.ts
  51. 27 0
      src/errors.ts
  52. 27 0
      src/http/app.ts
  53. 45 0
      src/http/middleware/auth.ts
  54. 36 0
      src/http/middleware/errorHandler.ts
  55. 29 0
      src/http/middleware/requestLog.ts
  56. 79 0
      src/http/routes/admin.ts
  57. 77 0
      src/http/routes/health.ts
  58. 348 0
      src/http/routes/tools.ts
  59. 90 0
      src/http/routes/webhooks.ts
  60. 49 0
      src/index.ts
  61. 108 0
      src/logger.ts
  62. 88 0
      src/queue/jobQueue.ts
  63. 90 0
      src/queue/worker.ts
  64. 34 0
      src/store/auditLog.ts
  65. 15 0
      src/store/db.ts
  66. 66 0
      src/store/idempotencyStore.ts
  67. 51 0
      src/types/chatwoot.ts
  68. 91 0
      src/types/tools.ts
  69. 31 0
      tests/config.test.ts
  70. 92 0
      tests/fixtures/chatwoot.ts
  71. 41 0
      tests/flowiseClient.test.ts
  72. 26 0
      tests/helpers/testConfig.ts
  73. 49 0
      tests/helpers/testServer.ts
  74. 98 0
      tests/integration/jobQueue.test.ts
  75. 231 0
      tests/integration/pipeline.test.ts
  76. 109 0
      tests/integration/tools.test.ts
  77. 129 0
      tests/integration/webhook.test.ts
  78. 60 0
      tests/logger.test.ts
  79. 99 0
      tests/messageNormalizer.test.ts
  80. 89 0
      tests/spamGate.test.ts
  81. 63 0
      tests/storeLocales.test.ts
  82. 40 0
      tests/ticketService.test.ts
  83. 8 0
      tsconfig.check.json
  84. 21 0
      tsconfig.json

+ 58 - 26
.env.example

@@ -1,35 +1,67 @@
-# === Chatwoot ===
-CHATWOOT_BASE_URL=https://app.chatwoot.com
-CHATWOOT_API_TOKEN=your_chatwoot_api_token
+# ─────────────────────────────────────────────────────────────────────────────
+# EKS Support Relay — environment template.
+# Copy to .env, fill in real values, and keep the file at chmod 0600.
+# NEVER commit a filled-in .env.
+# ─────────────────────────────────────────────────────────────────────────────
+
+# --- Runtime ---------------------------------------------------------------
+NODE_ENV=production
+PORT=3000
+# prod = compiled build (deterministic); dev = tsx watch (no restart on edits)
+RELAY_MODE=prod
+# Absolute path inside the container. Prisma resolves relative file: URLs
+# against prisma/, which is a common source of "wrong database" confusion.
+DATABASE_URL=file:/app/data/eks_relay.db
+
+# --- Chatwoot --------------------------------------------------------------
+CHATWOOT_BASE_URL=https://eksupport.easyklima.com
+CHATWOOT_API_TOKEN=
 CHATWOOT_ACCOUNT_ID=1
-CHATWOOT_BOT_AGENT_ID=
 CHATWOOT_TICKET_LABEL=ticket
+CHATWOOT_SPAM_LABEL=spam
+# Only enable once the `spam` label actually exists in the Chatwoot account.
+CHATWOOT_APPLY_SPAM_LABEL=false
+# Unassign the bot agent when a ticket is created.
+CHATWOOT_UNASSIGN_ON_TICKET=false
+# Webchat / API inbox — reserved for the later web widget adapter.
+CHATWOOT_API_INBOX_ID=
+CHATWOOT_API_IDENTITY_VALIDATION_TOKEN=
 
-# === Flowise ===
-FLOWISE_PREDICT_URL=http://localhost:3000/api/v1/prediction/your-chatflow-id
+# --- Flowise ---------------------------------------------------------------
+FLOWISE_PREDICT_URL=https://botek.easyklima.com/api/v1/prediction/09fc9332-8cdd-4142-8f0e-4259881fc7bf
 FLOWISE_API_KEY=
+# Optional; derived from FLOWISE_PREDICT_URL when empty. Used by /ready only.
+FLOWISE_BASE_URL=
+FLOWISE_TIMEOUT_MS=90000
 
-# === WordPress REST API (eksrelay_api.php mu-plugin) ===
-# URL bazowy sklepu WP – trasa REST budowana jako {WOOCOMMERCE_BASE_URL}/wp-json/eksrelay/v1
-# Endpointy są otwarte (bez autoryzacji) – wystarczy wgrać eksrelay_api.php jako mu-plugin.
-
-# === WordPress AJAX (stare endpointy – zachowane dla kompatybilności) ===
-WP_AJAX_URL=https://your-shop.com/wp-admin/admin-ajax.php
-
-# === WooCommerce REST API ===
-WOOCOMMERCE_BASE_URL=https://your-shop.com
-WOOCOMMERCE_CONSUMER_KEY=ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-WOOCOMMERCE_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# --- WooCommerce / WordPress ----------------------------------------------
+WOOCOMMERCE_BASE_URL=https://easyklima.com
+WOOCOMMERCE_CONSUMER_KEY=
+WOOCOMMERCE_CONSUMER_SECRET=
+# Bearer token for the eksrelay/v1 mu-plugin. Leave empty until the plugin
+# enforces it (see docs/DEPLOY.md).
+WP_STORE_API_SECRET=
+# Currencies actually configured in the shop's multicurrency plugin.
+STORE_CURRENCIES=PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP
 
-# === Relay Auth ===
-RELAY_SHARED_SECRET=change-me-to-a-random-string
+# --- Relay auth ------------------------------------------------------------
+# Shared with the Flowise custom tools as $vars.relay_shared_secret.
+RELAY_SHARED_SECRET=
+# Guards /admin/*. Empty means the admin endpoints are closed, not open.
+ADMIN_TOKEN=
 
-# === Wielowalutowość ===
-# Waluty aktywne w pluginie multicurrency WooCommerce (ISO 4217, oddzielone przecinkiem).
-# Musi odpowiadać rzeczywistej konfiguracji pluginu — inne waluty → fallback EUR.
-STORE_CURRENCIES=PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP
+# --- Behaviour -------------------------------------------------------------
+TENANT_ID=easyklima
+DEFAULT_LANGUAGE=pl
+TICKET_NUMBER_PREFIX=EKS
+WORKER_ENABLED=true
+WORKER_POLL_MS=1000
+WORKER_MAX_ATTEMPTS=3
+SPAM_GATE_ENABLED=true
+HTTP_TIMEOUT_MS=30000
 
-# === Logging ===
-# Poziom logowania: debug | info | warn | error  (domyślnie: info)
-# Ustaw debug tymczasowo podczas diagnozowania problemów.
+# --- Logging ---------------------------------------------------------------
 LOG_LEVEL=info
+# Switch on ONLY while debugging; it lets customer e-mails and message bodies
+# reach the logs. Secrets stay redacted either way.
+LOG_PII=false

+ 18 - 9
.gitignore

@@ -1,20 +1,29 @@
 # Dependencies
-/vendor/
+/node_modules/
 
-# Environment (zawiera sekrety — NIE commitować)
-.env
+# Build output
+/dist/
 
-# Lokalny konfig AI asystenta
-.claude/
+# Environment (contains secrets — never commit)
+.env
+.env.*
+!.env.example
 
-# Pliki testowe
-test5a.json
+# Runtime data (SQLite DB, backups)
+/data/
+*.db
+*.db-journal
+*.sqlite
+*.sqlite3
 
-# Logi
+# Logs
 *.log
 /logs/
 
-# Edytory
+# Local AI assistant config
+.claude/
+
+# Editors
 .idea/
 .vscode/
 *.swp

+ 29 - 0
Dockerfile

@@ -0,0 +1,29 @@
+# Single image for both modes. The repository itself is bind-mounted at runtime
+# (see docker-compose.yml), so day-to-day code changes never require a rebuild —
+# the entrypoint installs/builds on start and `dev` mode additionally watches.
+FROM node:22-bookworm-slim
+
+# openssl is required by Prisma's query engine; ca-certificates for outbound TLS.
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends openssl ca-certificates curl \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Warm the layer cache with the dependency manifests only.
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY . .
+RUN npx prisma generate
+
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+ENV NODE_ENV=production
+EXPOSE 3000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+  CMD curl -fsS http://127.0.0.1:3000/health || exit 1
+
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

+ 148 - 86
README.md

@@ -1,121 +1,183 @@
-# EKSRelay
+# EKS Support Relay
 
-PHP relay pomiędzy Chatwoot, Flowise i WooCommerce. Odbiera webhooki z Chatwoot, przekazuje wiadomości do Flowise (LLM agent) i wystawia narzędzia (`/tools/*`) wywoływane przez agenta.
+Integration hub between **Chatwoot** (support inbox), **Flowise** (AI agent),
+**WooCommerce** and the **WordPress `eksrelay/v1` Store API** for the EasyKlima
+support system.
 
-## Architektura
+Version 2 is a Node.js/TypeScript rewrite of the original PHP relay. The PHP
+implementation remains reachable in git history — see [Migration](#migration-from-the-php-relay).
+
+## What it does
 
 ```
-Klient → CHATWOOT → EKSRELAY /webhooks/chatwoot → FLOWISE (Tool Agent)
-                                                        ↓ narzędzia
-                                              EKSRELAY /tools/*
-                                                        ↓
-                                                  WOOCOMMERCE REST API
-                                                  WP AJAX (mu-plugin)
-                    ← odpowiedź ← EKSRELAY ← FLOWISE
+customer e-mail
+      │
+      ▼
+  Chatwoot  ──webhook message_created──▶  EKS Relay  ──▶  Flowise (Tool Agent)
+      ▲                                     │  ▲               │
+      │                                     │  └── /tools/* ───┘
+      └────────── outgoing reply ───────────┘
+                  or ticket handoff
 ```
 
-## Uruchomienie lokalne
+1. Chatwoot posts `message_created` to `POST /webhooks/chatwoot`.
+2. The relay validates, deduplicates and queues the event, then answers `202`.
+3. A worker checks ticket mode, runs the spam gate, and calls Flowise.
+4. The agent may call back into `/tools/*` for live shop data.
+5. The answer is posted back to Chatwoot, or the conversation is handed off to a
+   human with a ticket number.
+
+## Endpoints
+
+| Method | Path | Auth | Purpose |
+|---|---|---|---|
+| GET | `/health` | — | Liveness. No dependencies touched. |
+| GET | `/ready` | — | Readiness plus per-dependency status. No secrets. |
+| POST | `/webhooks/chatwoot` | — | Chatwoot `message_created` intake. |
+| POST | `/tools/get_order_data` | Bearer | Order lookup with e-mail ownership check. |
+| POST | `/tools/get_product_data` | Bearer | Product by id / SKU / search. |
+| POST | `/tools/get_shipping_data` | Bearer | Zones, or per-zone methods and costs. |
+| POST | `/tools/get_payment_methods` | Bearer | Enabled payment gateways. |
+| POST | `/tools/get_product_compatibility` | Bearer | Car ↔ product compatibility. |
+| POST | `/tools/get_car_data` | Bearer | Brands / models / engines. |
+| POST | `/tools/new_ticket` | Bearer | Create (or reuse) a ticket, hand off. |
+| GET | `/admin/events` | Bearer (admin) | Recent audit trail. |
+| GET | `/admin/jobs` | Bearer (admin) | Queue stats and dead jobs. |
+| GET | `/admin/messages` | Bearer (admin) | Processed-message records. |
+
+`/tools/*` uses `Authorization: Bearer <RELAY_SHARED_SECRET>`.
+`/admin/*` uses `Authorization: Bearer <ADMIN_TOKEN>`; when `ADMIN_TOKEN` is
+empty the admin routes are **closed**, not open.
+
+## Architecture
 
-```bash
-composer install
-cp .env.example .env
-# uzupełnij .env
-php -S 0.0.0.0:8080 -t public
+```
+src/
+  config.ts                 zod-validated environment contract
+  logger.ts                 JSON logger + secret/PII redactor
+  errors.ts                 RelayError (http code + stable error code)
+  clients/
+    httpClient.ts           fetch wrapper: timeouts, safe labels
+    chatwootClient.ts       conversations, labels, attributes, messages
+    flowiseClient.ts        prediction call + response normalisation
+    wooClient.ts            WooCommerce REST v3, WPML search fallbacks
+    wpStoreClient.ts        eksrelay/v1 mu-plugin endpoints
+  domain/
+    messageNormalizer.ts    Chatwoot payload → SupportMessageEvent
+    spamGate.ts             bounce / autoreply / newsletter filtering
+    ticketService.ts        ticket numbering + idempotent handoff
+    conversationPipeline.ts the single decision path per message
+    storeLocales.ts         language → currency with shop-aware fallback
+    formatters.ts           curated order/product views for the agent
+  queue/
+    jobQueue.ts             DB-backed queue with retry/backoff
+    worker.ts               in-process poller
+  store/
+    db.ts                   Prisma client
+    idempotencyStore.ts     ProcessedMessage claim/status
+    auditLog.ts             redacted audit events
+  http/                     express app, routes, middleware
 ```
 
-## Zmienne środowiskowe (`.env`)
-
-| Zmienna | Opis |
-|---|---|
-| `CHATWOOT_BASE_URL` | URL instancji Chatwoot (np. `https://eksupport.easyklima.com`) |
-| `CHATWOOT_API_TOKEN` | Token API Chatwoot (Settings → Account → API) |
-| `CHATWOOT_ACCOUNT_ID` | ID konta Chatwoot (z URL `/accounts/X/`) |
-| `CHATWOOT_BOT_AGENT_ID` | ID agenta-bota (opcjonalne) |
-| `CHATWOOT_TICKET_LABEL` | Label oznaczający ręczną obsługę (domyślnie: `ticket`) |
-| `FLOWISE_PREDICT_URL` | URL endpointu `/api/v1/prediction/<chatflow-id>` |
-| `FLOWISE_API_KEY` | Klucz API Flowise (jeśli włączone) |
-| `WP_AJAX_URL` | URL WordPress AJAX: `https://sklep.pl/wp-admin/admin-ajax.php` |
-| `WOOCOMMERCE_BASE_URL` | URL sklepu WooCommerce |
-| `WOOCOMMERCE_CONSUMER_KEY` | Klucz WooCommerce REST API (`ck_...`) |
-| `WOOCOMMERCE_CONSUMER_SECRET` | Secret WooCommerce REST API (`cs_...`) |
-| `RELAY_SHARED_SECRET` | Losowy token chroniący endpointy `/tools/*` |
+Channel adapters are the extension point: any new channel only has to produce a
+`SupportMessageEvent`, and the whole AI/ticket/spam pipeline applies unchanged.
 
-## Endpointy
+## Persistence
 
-### Webhook
-| Endpoint | Opis |
-|---|---|
-| `POST /webhooks/chatwoot` | Odbiera eventy z Chatwoot. Przetwarza tylko `message_created` + `incoming`. |
+SQLite via Prisma (`prisma/schema.prisma`):
 
-### Tools (wywoływane przez Flowise)
-Wszystkie wymagają nagłówka `Authorization: Bearer <RELAY_SHARED_SECRET>`.
+- `ProcessedMessage` — idempotency guard, unique on `(source, messageId)`.
+- `Job` — queued work with `attempts`, `lastError` (redacted), backoff.
+- `Ticket` — one row per conversation, unique on `conversationId`.
+- `AuditEvent` — redacted decision trail.
 
-| Endpoint | Źródło danych | Opis |
-|---|---|---|
-| `POST /tools/get_order_data` | WooCommerce REST API | Zamówienie po numerze lub emailu |
-| `POST /tools/get_product_data` | WooCommerce REST API | Produkt po ID, SKU lub frazie |
-| `POST /tools/get_shipping_data` | WooCommerce REST API | Strefy i metody wysyłki |
-| `POST /tools/get_payment_methods` | WooCommerce REST API | Dostępne metody płatności |
-| `POST /tools/get_car_data` | **WP AJAX (mu-plugin)** | Dane auta z bazy pojazdów WP |
-| `POST /tools/get_product_compatibility` | **WP AJAX (mu-plugin)** | Kompatybilność produktu z autem |
-| `POST /tools/new_ticket` | Chatwoot API | Tworzy ticket, dodaje label `ticket` |
+### Browsing the database
 
-## Zależność: mu-plugin WordPress (`aiac_chat_api.php`)
+```bash
+# On the server: start Prisma Studio bound to localhost only,
+# then reach it through an SSH tunnel.
+ssh -i <key> -L 5555:127.0.0.1:5555 ubuntu@<host>
+docker compose -f docker-compose.yml -f docker-compose.studio.yml up -d studio
+# open http://127.0.0.1:5555 — stop it again when done
+docker compose -f docker-compose.yml -f docker-compose.studio.yml down
+```
 
-Dwa endpointy — `get_car_data` i `get_product_compatibility` — **nie korzystają z WooCommerce REST API**, lecz bezpośrednio z WordPress AJAX udostępnianego przez mu-plugin `aiac_chat_api.php`.
+Locally: `npm run db:studio`.
 
-### Dlaczego
+Prisma Studio has full read/write access and **must never be exposed publicly**.
 
-Dane o samochodach (`marka/model/rok/silnik → typ gazu, ilość, adapter`) i sprawdzanie kompatybilności produktu z autem są przechowywane w WordPressie jako:
-- custom post type: `car`
-- taxonomie: `car_model`, `car_production_year`
-- pola ACF: `ac_gas_type`, `ac_gas_amount`, `adapters`, itp.
+## Local development
 
-Standardowe WooCommerce REST API nie wystawia tych danych — stąd konieczność korzystania z pluginu.
+```bash
+npm install
+cp .env.example .env        # fill in real values, chmod 600
+export DATABASE_URL="file:../data/eks_relay.db"
+npx prisma migrate deploy
+npm run dev                 # tsx watch
+```
 
-### Aktualny stan (tymczasowy)
+```bash
+npm run lint     # tsc type-check over src, tests and scripts
+npm test         # node:test suite
+npm run build    # compile to dist/
+npm run events   # CLI audit-trail viewer
+```
 
-Plugin `aiac_chat_api.php` jest **starym rozwiązaniem** z poprzedniego chatbota. EKSRelay wywołuje jego publiczne AJAX endpointy:
+## Docker
 
+```bash
+docker compose config       # validate
+docker compose up -d --build
+docker compose ps
+docker compose logs -f relay
 ```
-GET https://easyklima.pl/wp-admin/admin-ajax.php?action=chat_get_car_data&car_brand=Toyota&...
-GET https://easyklima.pl/wp-admin/admin-ajax.php?action=chat_get_product_compatibility&...
+
+The repository is bind-mounted into the container and the entrypoint installs,
+generates and migrates on start, so **a code change needs a restart, not a
+rebuild**:
+
+```bash
+git pull && docker compose restart relay
 ```
 
-Endpointy są publiczne (`nopriv`) i nie wymagają uwierzytelnienia.
+With `RELAY_MODE=dev` the container runs `tsx watch`, so edits are picked up
+without even a restart. `RELAY_MODE=prod` compiles once and runs `dist/`.
+
+A rebuild is only needed when the base image or system packages change.
+
+## Configuration
 
-### Docelowe rozwiązanie (TODO)
+See [`.env.example`](.env.example) for every variable with inline notes. Boot
+fails loudly with the offending variable names (never their values) when the
+environment is incomplete.
 
-Plugin powinien zostać **przebudowany** tak, żeby:
-1. Wystawiał dedykowane REST API zamiast AJAX (`register_rest_route` → `/wp-json/aiac/v1/car-data`)
-2. Wprowadzał uwierzytelnienie (shared secret lub wp-nonce)
-3. Był niezależny od starego kodu chatbota (usunięcie funkcji ticketów, Baselinker itp.)
-4. Ewentualnie obsługiwał też `get_product_data` z wariantami i wieloma walutami (WCML), czego obecne rozwiązanie przez WC REST API nie pokrywa w pełni
+## Logging
 
-Do tego czasu EKSRelay korzysta ze starych endpointów AJAX — działa, ale jest kruche (bez auth, zależne od legacy kodu).
+Structured JSON on stdout/stderr. Secrets (tokens, bearer headers, Woo consumer
+keys, credentialed query strings) are **always** redacted. PII (customer e-mail,
+names, message bodies) is redacted unless `LOG_PII=true`, which is meant for
+temporary debugging only.
 
-## Flowise — konfiguracja
+## Documentation
 
-Narzędzia Flowise znajdują się w katalogu `flowise-tools/`. Każdy plik JSON to definicja do zaimportowania w Flowise UI (Tools → Add Tool → Import).
+- [`docs/DEPLOY.md`](docs/DEPLOY.md) — server layout, Traefik, Cloudflare, updates.
+- [`docs/SMOKE_TEST.md`](docs/SMOKE_TEST.md) — the verification runbook.
+- [`docs/MIGRATION.md`](docs/MIGRATION.md) — PHP → Node differences and fallback.
 
-W chatflow Flowise należy ustawić zmienne:
+## Migration from the PHP relay
 
-| Zmienna | Wartość |
-|---|---|
-| `relay_base` | URL EKSRelay (np. `http://localhost:8080` lub produkcyjny) |
-| `relay_shared_secret` | Wartość `RELAY_SHARED_SECRET` z `.env` |
+The PHP implementation lived in `src/` and `public/` until the v2 rewrite. It is
+preserved at the `php-legacy` tag and stays deployed on the EasyKlima web server
+as a fallback until the Node relay has passed a real e-mail test. See
+[`docs/MIGRATION.md`](docs/MIGRATION.md).
 
-## Konfiguracja Chatwoot
+## Flowise tools
 
-W Chatwoot → Settings → Integrations → Webhooks → Add:
-- URL: `https://<adres-relay>/webhooks/chatwoot`
-- Zdarzenia: ✅ `message_created`
+`flowise-tools/*.json` holds the exported custom-tool definitions. The relay's
+response contracts are built to match them exactly — changing a response shape
+means updating the corresponding tool in Flowise as well.
 
-## Wdrożenie
+## WordPress plugin
 
-1. Sklonuj repo na serwer (najlepiej ten sam co Flowise)
-2. `composer install --no-dev`
-3. Skonfiguruj `.env` z produkcyjnymi danymi
-4. Ustaw PHP-FPM + nginx lub uruchom `php -S 0.0.0.0:8080 -t public`
-5. Zaktualizuj `relay_base` w zmiennych Flowise na publiczny URL
-6. Dodaj webhook w Chatwoot
+`wp-plugins/eksrelay_api.php` is the mu-plugin providing `eksrelay/v1`. It is
+deployed to the shop's WordPress, not to this service.

+ 0 - 13
composer.json

@@ -1,13 +0,0 @@
-{
-    "name": "aiac/eks-relay",
-    "description": "EKSRelay – stateless communication relay between Chatwoot, Flowise and WooCommerce",
-    "type": "project",
-    "require": {
-        "php": ">=8.2"
-    },
-    "autoload": {
-        "psr-4": {
-            "EKSRelay\\": "src/"
-        }
-    }
-}

+ 34 - 0
docker-compose.studio.yml

@@ -0,0 +1,34 @@
+# Local-only Prisma Studio. Never exposed through Traefik: it binds to
+# 127.0.0.1 on the host, so reaching it requires an SSH tunnel:
+#
+#   ssh -i <key> -L 5555:127.0.0.1:5555 ubuntu@<host>
+#   docker compose -f docker-compose.yml -f docker-compose.studio.yml up -d studio
+#   open http://127.0.0.1:5555
+#
+# Tear it down again when you are done: it has full read/write access to the DB.
+services:
+  studio:
+    image: eks-relay:local
+    container_name: eks-relay-studio
+    restart: "no"
+    env_file: .env
+    environment:
+      - DATABASE_URL=file:/app/data/eks_relay.db
+    volumes:
+      - .:/app
+      - relay_node_modules:/app/node_modules
+      - ./data:/app/data
+    entrypoint: ["npx", "prisma", "studio", "--port", "5555", "--hostname", "0.0.0.0", "--browser", "none"]
+    ports:
+      - "127.0.0.1:5555:5555"
+    networks:
+      - web
+
+volumes:
+  relay_node_modules:
+    external: true
+    name: eks_relay_relay_node_modules
+
+networks:
+  web:
+    external: true

+ 48 - 0
docker-compose.yml

@@ -0,0 +1,48 @@
+services:
+  relay:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: eks-relay:local
+    container_name: eks-relay
+    restart: always
+    env_file: .env
+    environment:
+      - NODE_ENV=production
+      - PORT=3000
+      # Absolute path: Prisma resolves relative file: URLs against prisma/.
+      - DATABASE_URL=file:/app/data/eks_relay.db
+    volumes:
+      # The whole repository is bind-mounted, so `git pull` + restart is enough
+      # to ship a code change. node_modules stays in a named volume so the
+      # container's Linux binaries are not shadowed by the host's.
+      - .:/app
+      - relay_node_modules:/app/node_modules
+      - ./data:/app/data
+    networks:
+      - web
+    labels:
+      - traefik.enable=true
+      - traefik.docker.network=web
+      - traefik.http.routers.eksrelay.rule=Host(`eks-relay.easyklima.com`)
+      - traefik.http.routers.eksrelay.entrypoints=https
+      - traefik.http.routers.eksrelay.tls=true
+      - traefik.http.routers.eksrelay.tls.certresolver=letsencrypt
+      - traefik.http.services.eksrelay.loadbalancer.server.port=3000
+      - traefik.http.routers.eksrelay.middlewares=eksrelay-headers
+      - traefik.http.middlewares.eksrelay-headers.headers.sslredirect=true
+      - traefik.http.middlewares.eksrelay-headers.headers.stsseconds=63072000
+      - traefik.http.middlewares.eksrelay-headers.headers.stsincludesubdomains=true
+      - traefik.http.middlewares.eksrelay-headers.headers.referrerpolicy=no-referrer
+      # HTTP router only exists so Let's Encrypt can answer the ACME challenge.
+      - traefik.http.routers.eksrelay-http.rule=Host(`eks-relay.easyklima.com`)
+      - traefik.http.routers.eksrelay-http.entrypoints=http
+      - traefik.http.routers.eksrelay-http.middlewares=eksrelay-to-https
+      - traefik.http.middlewares.eksrelay-to-https.redirectscheme.scheme=https
+
+volumes:
+  relay_node_modules:
+
+networks:
+  web:
+    external: true

+ 50 - 0
docker/entrypoint.sh

@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Startup sequence for the bind-mounted repo:
+#   1. install dependencies only when package-lock.json actually changed;
+#   2. regenerate the Prisma client only when the schema actually changed;
+#   3. apply pending migrations;
+#   4. start in dev (watch) or prod (compiled) mode.
+#
+# The stamp files live under node_modules/, so a `git pull` that touches only
+# application code goes straight to step 4.
+set -euo pipefail
+
+cd /app
+mkdir -p data
+STAMP_DIR="node_modules/.eks-stamps"
+
+hash_of() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
+
+needs_refresh() {
+  local file="$1" stamp="$STAMP_DIR/$2"
+  [ -f "$stamp" ] || return 0
+  [ "$(cat "$stamp")" = "$(hash_of "$file")" ] && return 1 || return 0
+}
+
+record() { mkdir -p "$STAMP_DIR"; hash_of "$1" > "$STAMP_DIR/$2"; }
+
+if [ ! -d node_modules/express ] || needs_refresh package-lock.json lock; then
+  echo "[entrypoint] installing dependencies"
+  npm ci --no-audit --no-fund
+  record package-lock.json lock
+else
+  echo "[entrypoint] dependencies up to date"
+fi
+
+if [ ! -d node_modules/.prisma/client ] || needs_refresh prisma/schema.prisma schema; then
+  echo "[entrypoint] generating prisma client"
+  npx prisma generate
+  record prisma/schema.prisma schema
+fi
+
+echo "[entrypoint] applying database migrations"
+npx prisma migrate deploy
+
+if [ "${RELAY_MODE:-prod}" = "dev" ]; then
+  echo "[entrypoint] starting in dev mode (tsx watch — no rebuild needed)"
+  exec npx tsx watch src/index.ts
+fi
+
+echo "[entrypoint] building and starting in prod mode"
+npm run build
+exec node dist/index.js

+ 170 - 0
docs/DEPLOY.md

@@ -0,0 +1,170 @@
+# Deployment — EKS Support Relay
+
+## Target
+
+| | |
+|---|---|
+| Host | `acmycar`, `18.168.156.244` (the Chatwoot host) |
+| SSH | `ssh -i <maciek_priv> ubuntu@18.168.156.244` |
+| Path | `/home/ubuntu/eks_relay` (a real git checkout of the Gogs repo) |
+| Domain | `https://eks-relay.easyklima.com` |
+| Reverse proxy | Traefik `traefik-traefik-1`, docker network `web` |
+| DNS | Cloudflare zone `easyklima.com`, proxied A record |
+
+The relay is co-located with Chatwoot rather than with Flowise: the Chatwoot
+webhook is the latency-sensitive hop (Chatwoot waits for the `202`), while the
+Flowise → `/tools/*` calls happen inside an already long-running LLM turn.
+
+### Why `easyklima.com` and not `easyklima.pl`
+
+`eksupport.easyklima.com` and `botek.easyklima.com` already live in the
+`easyklima.com` Cloudflare zone and terminate on the same Traefik. Putting the
+relay in the same zone keeps one certificate resolver, one WAF policy surface
+and one set of DNS conventions. `easyklima.pl` points at the shop's origin and
+would need separate Cloudflare and Traefik work for no benefit.
+
+> Note: the zone has a wildcard `*.easyklima.com` CNAME pointing at the shop
+> origin. Without an explicit A record, `eks-relay.easyklima.com` resolves to the
+> **shop**, not to Chatwoot. The explicit record below is mandatory, not optional.
+
+## 1. DNS (Cloudflare)
+
+Create a proxied A record matching the `eksupport` pattern:
+
+| Type | Name | Content | Proxy |
+|---|---|---|---|
+| A | `eks-relay` | `18.168.156.244` | Proxied (orange cloud) |
+
+```bash
+# CLOUDFLARE_TOKEN comes from the local secrets file — never inline it.
+ZONE=3ff752c975f4bfc6286f0f357f306c2f
+curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" \
+  -H "Authorization: Bearer $CLOUDFLARE_TOKEN" \
+  -H "Content-Type: application/json" \
+  --data '{"type":"A","name":"eks-relay","content":"18.168.156.244","proxied":true,"ttl":1}'
+```
+
+Verify:
+
+```bash
+dig +short eks-relay.easyklima.com    # Cloudflare anycast IPs
+```
+
+### Cloudflare and automated POSTs
+
+Cloudflare's managed challenge can block server-to-server POSTs from Chatwoot
+and Flowise. If that happens, add a **narrow** WAF skip rule — scoped to this
+hostname and these paths only, never a zone-wide bypass:
+
+```
+Expression:
+(http.host eq "eks-relay.easyklima.com" and
+ (starts_with(http.request.uri.path, "/webhooks/") or
+  starts_with(http.request.uri.path, "/tools/")))
+
+Action: Skip → Managed Rules, Bot Fight Mode, Rate limiting
+```
+
+The endpoints stay protected by their own bearer secrets; the skip rule only
+removes Cloudflare's browser-oriented challenges.
+
+## 2. Server checkout
+
+```bash
+ssh -i <key> ubuntu@18.168.156.244
+git clone https://gogs.tenteg.es/aiac/eks_relay.git /home/ubuntu/eks_relay
+cd /home/ubuntu/eks_relay
+git config user.name  "Maciek"
+git config user.email "maciek@aiac.local"
+```
+
+It is a normal checkout, so `git pull` and `git push` both work on the server.
+
+## 3. Environment
+
+```bash
+cp .env.example .env
+chmod 600 .env
+# fill in the real values (migrated from the PHP relay's .env.php)
+```
+
+Required before first start: `CHATWOOT_API_TOKEN`, `FLOWISE_PREDICT_URL`,
+`FLOWISE_API_KEY`, `WOOCOMMERCE_CONSUMER_KEY`, `WOOCOMMERCE_CONSUMER_SECRET`,
+`RELAY_SHARED_SECRET`, `ADMIN_TOKEN`.
+
+`RELAY_SHARED_SECRET` **must** stay identical to the value configured in Flowise
+as `$vars.relay_shared_secret`, otherwise every tool call returns 401.
+
+## 4. Start
+
+```bash
+cd /home/ubuntu/eks_relay
+sudo docker compose config          # validate
+sudo docker compose up -d --build
+sudo docker compose ps
+sudo docker compose logs -f relay
+```
+
+The `web` network already exists (Traefik owns it); the compose file joins it as
+external.
+
+## 5. Verify
+
+```bash
+curl -s https://eks-relay.easyklima.com/health | jq
+curl -s https://eks-relay.easyklima.com/ready  | jq
+```
+
+Then run [`SMOKE_TEST.md`](SMOKE_TEST.md).
+
+## 6. Updating
+
+```bash
+cd /home/ubuntu/eks_relay
+git pull
+sudo docker compose restart relay
+```
+
+No rebuild: the repo is bind-mounted and the entrypoint reinstalls dependencies
+only when `package-lock.json` changed, regenerates the Prisma client only when
+`prisma/schema.prisma` changed, and always applies pending migrations.
+
+Rebuild (`--build`) only when the Dockerfile or its base image changes.
+
+## Database
+
+SQLite at `/home/ubuntu/eks_relay/data/eks_relay.db`, bind-mounted into the
+container at `/app/data`. It is gitignored.
+
+Back it up before a schema change:
+
+```bash
+cp data/eks_relay.db data/eks_relay.db.$(date +%F-%H%M)
+```
+
+Browse it via Prisma Studio over an SSH tunnel — see the README. Studio is
+never routed through Traefik.
+
+## Chatwoot webhook
+
+Point the Chatwoot bot/webhook at:
+
+```
+https://eks-relay.easyklima.com/webhooks/chatwoot
+```
+
+Keep the old PHP URL noted until the Node relay has passed a real e-mail test;
+switching back is a one-field change in the Chatwoot panel.
+
+## Known follow-ups
+
+- **`wp-plugins/eksrelay_api.php` is unauthenticated.** Its routes use
+  `permission_callback => __return_true`. The relay already sends
+  `Authorization: Bearer <WP_STORE_API_SECRET>` when that variable is set;
+  enforcing it in the plugin is a separate, coordinated change on the shop's
+  WordPress.
+- **`npm audit` reports a high-severity advisory** in `deepmerge-ts`, reached
+  through `@prisma/config` → `prisma`. That is the Prisma **CLI** dependency
+  chain (build/migration time), not the runtime `@prisma/client` used to serve
+  requests. The only offered fix downgrades Prisma to 6.12; the advisory is
+  therefore accepted and tracked rather than force-fixed.

+ 74 - 0
docs/MIGRATION.md

@@ -0,0 +1,74 @@
+# Migration: PHP relay → Node.js/TypeScript relay
+
+## Status
+
+The repository now contains the Node.js/TypeScript implementation. The PHP code
+is preserved in git history at the tag `php-legacy` and remains **deployed and
+untouched** on the shop's web server at
+
+```
+ubuntu@35.179.18.165:/home/ubuntu/containers/easyklima/apache_data/html/eks_relay
+```
+
+so a rollback is a webhook-URL change in Chatwoot, nothing more.
+
+## What changed, and why
+
+| Area | PHP relay | Node relay |
+|---|---|---|
+| Flowise auth | sent a literal `Bearer ***` placeholder | sends the real `FLOWISE_API_KEY`, and only when one is configured |
+| Idempotency | none — a webhook retry meant a second answer to the customer | `ProcessedMessage` unique on `(source, messageId)`, plus a content-hash surrogate id when the payload carries none |
+| Webhook timing | synchronous, waited up to 60 s for Flowise | persists a job, answers `202`, worker does the work with retry/backoff |
+| Spam | not implemented | rule-based gate in front of Flowise (bounces, autoreplies, newsletters, empty/attachment-only mail, automated senders, `Auto-Submitted`/`List-Unsubscribe` headers) |
+| Ticket number | the bare conversation id | `EKS-YYYYMMDD-<conversationId>` |
+| Ticket idempotency | re-read Chatwoot attributes only | local `Ticket` table keyed by `conversationId` **and** adoption of an existing Chatwoot `ticket_number` |
+| Unassign on handoff | `unassignConversation()` existed but was never called | called when `CHATWOOT_UNASSIGN_ON_TICKET=true` |
+| Ticket-mode check | label or `handoff` | label, `handoff`, **or** a non-zero `ticket_number` |
+| Health | none | `/health` and `/ready` with per-dependency status |
+| Logging | wrote request bodies to a file, including PII | structured JSON, secrets always redacted, PII behind `LOG_PII` |
+| Config | ad-hoc `getenv` | zod schema; boot fails loudly, naming variables but never values |
+| Message body | passed raw | quoted replies, signatures and HTML stripped before the LLM sees them |
+| Tests | none | 86 tests: fixtures, spam rules, redaction, idempotency, queue, pipeline |
+| Deployment | files in the shop's webroot | own container, own Traefik router, own domain, own logs |
+
+## What deliberately did **not** change
+
+The `/tools/*` request and response contracts are byte-compatible with the
+deployed Flowise custom tools in `flowise-tools/`:
+
+- `get_order_data` still returns `{ok, data, currency, currency_fallback?, currency_note?}`
+  and still answers `code: "UNAUTHORIZED"` on an e-mail/order mismatch, which is
+  what drives the tool's `[ORDER EMAIL MISMATCH]` retry prompt.
+- `get_product_data` still returns a single object or an array, so the tool's
+  kit-vs-single filtering and Number→ID mapping keep working.
+- `get_shipping_data` still returns zones with ISO country codes when called
+  without `zoneId`, and `{methods: [...]}` from the WP endpoint when called with
+  one.
+- `get_payment_methods` still degrades to `200 {ok:false, code:"NOT_IMPLEMENTED"}`
+  rather than a 5xx when the Woo key lacks admin scope.
+- `get_product_compatibility` keeps the WPML pre-resolution of a product name to
+  an id and the post-hoc title translation.
+- `new_ticket` still returns `{ok, ticketNumber, status}`.
+
+The WPML/multicurrency behaviour (`storeLocales.ts`) is a direct port of
+`StoreLocales.php`, including the EUR fallback and the note text.
+
+## Migration order
+
+1. Deploy the Node relay to its own domain; leave PHP running. ✔
+2. Verify with `docs/SMOKE_TEST.md`, steps 1–6. ✔
+3. Point the Chatwoot webhook at the Node relay for the **test** inbox and run a
+   real e-mail test (step 7).
+4. Only then consider the production inbox `info@easyklima.com`.
+5. Retire the PHP deployment once the Node relay has run clean for a while.
+
+## Fallback
+
+Set the Chatwoot webhook back to:
+
+```
+https://easyklima.pl/eks_relay/public/index.php?__path=/webhooks/chatwoot
+```
+
+Nothing else needs undoing: the two relays share no state, and the Node relay's
+SQLite database is entirely its own.

+ 156 - 0
docs/SMOKE_TEST.md

@@ -0,0 +1,156 @@
+# Smoke test runbook — EKS Support Relay
+
+Run top to bottom after every deployment. Steps 1–5 are non-destructive.
+Steps 6–7 write to Chatwoot and must use a **test conversation**.
+
+Set up once per shell:
+
+```bash
+BASE=https://eks-relay.easyklima.com
+SECRET=...   # RELAY_SHARED_SECRET, read from .env — do not echo it
+ADMIN=...    # ADMIN_TOKEN
+```
+
+## 1. Liveness
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' $BASE/health   # expect 200
+curl -s $BASE/health | jq
+```
+
+Expect `ok: true`, a service name, and an uptime. No secrets in the body.
+
+## 2. Readiness
+
+```bash
+curl -s $BASE/ready | jq
+```
+
+Expect `ok: true` and a `dependencies` block. Each dependency reports
+`{ok, status, target}` where `target` is host+path only. `configured` is a set
+of booleans — it says *whether* a secret is set, never what it is.
+
+`database` and `chatwoot` must be `ok`. `woocommerce` / `wpStore` may report
+non-200 if the shop's Cloudflare challenges the call; that is a routing issue,
+not a relay fault.
+
+## 3. Tools auth
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' -X POST $BASE/tools/get_payment_methods \
+  -H 'Content-Type: application/json' -d '{}'                      # expect 401
+
+curl -s -o /dev/null -w '%{http_code}\n' -X POST $BASE/tools/get_payment_methods \
+  -H 'Content-Type: application/json' -H 'Authorization: Bearer wrong' -d '{}'  # expect 401
+```
+
+## 4. Read-only tool calls
+
+```bash
+curl -s -X POST $BASE/tools/get_payment_methods \
+  -H 'Content-Type: application/json' -H "Authorization: Bearer $SECRET" \
+  -d '{"language":"pl"}' | jq
+
+curl -s -X POST $BASE/tools/get_shipping_data \
+  -H 'Content-Type: application/json' -H "Authorization: Bearer $SECRET" \
+  -d '{"language":"pl"}' | jq '.data[:3]'
+
+curl -s -X POST $BASE/tools/get_shipping_data \
+  -H 'Content-Type: application/json' -H "Authorization: Bearer $SECRET" \
+  -d '{"country":"DE","language":"de"}' | jq
+```
+
+`get_payment_methods` needs admin-level Woo consumer keys; if the key is
+read-only it answers `200` with `ok:false, code:"NOT_IMPLEMENTED"` — that is the
+designed degradation, and the Flowise tool surfaces it as an explanation.
+
+## 5. Webhook fixture and idempotency
+
+Use a conversation id that exists in Chatwoot but has no live customer.
+
+```bash
+MSG=$RANDOM
+PAYLOAD=$(cat <<JSON
+{
+  "event": "message_created",
+  "id": $MSG,
+  "message_type": "incoming",
+  "content_type": "incoming_email",
+  "content": "Test techniczny relaya — prosze zignorowac.",
+  "conversation": {
+    "id": 1311, "inbox_id": 1, "channel": "Channel::Email",
+    "labels": [], "custom_attributes": {},
+    "additional_attributes": {"mail_subject": "Relay smoke test"},
+    "meta": {"sender": {"id": 1, "name": "Relay Test", "email": "relay-test@example.com"}}
+  },
+  "sender": {"id": 1, "name": "Relay Test", "email": "relay-test@example.com"}
+}
+JSON
+)
+
+# First delivery → 202 with a jobId
+curl -s -X POST $BASE/webhooks/chatwoot -H 'Content-Type: application/json' -d "$PAYLOAD" | jq
+
+# Same message id again → 200 with duplicate:true and NO new job
+curl -s -X POST $BASE/webhooks/chatwoot -H 'Content-Type: application/json' -d "$PAYLOAD" | jq
+```
+
+Confirm in the DB:
+
+```bash
+curl -s "$BASE/admin/messages?conversationId=1311" -H "Authorization: Bearer $ADMIN" | jq
+curl -s "$BASE/admin/events?conversationId=1311"   -H "Authorization: Bearer $ADMIN" | jq
+curl -s "$BASE/admin/jobs"                          -H "Authorization: Bearer $ADMIN" | jq
+```
+
+Expect exactly one `ProcessedMessage` row for that message id and exactly one
+job. On the server the same data is available offline:
+
+```bash
+sudo docker compose exec relay npm run events -- --conversation 1311
+sudo docker compose exec relay npm run events -- --jobs
+```
+
+Non-actionable events must be skipped with `200`:
+
+```bash
+for t in '"outgoing"' ; do
+  curl -s -X POST $BASE/webhooks/chatwoot -H 'Content-Type: application/json' \
+    -d "{\"event\":\"message_created\",\"id\":$RANDOM,\"message_type\":$t,\"content\":\"x\",\"conversation\":{\"id\":1311}}" | jq -c
+done
+# expect {"ok":true,"skipped":true,"reason":"not_incoming:outgoing"}
+```
+
+## 6. Ticket creation (writes to Chatwoot — test conversation only)
+
+```bash
+curl -s -X POST $BASE/tools/new_ticket \
+  -H 'Content-Type: application/json' -H "Authorization: Bearer $SECRET" \
+  -d '{"conversationId": <TEST_CONV_ID>, "summary": "Smoke test handoff"}' | jq
+```
+
+Expect `{"ok":true,"ticketNumber":"EKS-YYYYMMDD-<id>","status":"created"}`.
+
+Call it a second time — expect the **same** number with `"status":"existing"`.
+
+In Chatwoot verify the conversation now has the `ticket` label and a
+`ticket_number` custom attribute. Afterwards, remove the label and clear the
+attribute to return the test conversation to its previous state.
+
+## 7. End-to-end e-mail test
+
+1. Send an e-mail to the test inbox `aiac-aws@easyklima.com`.
+2. Wait for Chatwoot's IMAP poll and confirm a conversation appears
+   (filter: *status = all*, inbox *Skrzynka EasyKlima support*).
+3. `GET /admin/events?conversationId=<id>` → `webhook_accepted`, then
+   `flowise_response`, then `reply_sent`.
+4. Confirm the reply is visible in Chatwoot and in Flowise → *View Messages*
+   under session `chatwoot:<id>`.
+5. Force a handoff (ask something that requires a human). Confirm the `ticket`
+   label, the `ticket_number`, and that further messages log
+   `skipped_ticket_mode` instead of calling Flowise.
+
+## 8. Rollback
+
+Point the Chatwoot webhook back at the PHP relay URL. The PHP deployment on the
+shop's web server is untouched and remains functional.

+ 23 - 0
flowise-tools/README.md

@@ -0,0 +1,23 @@
+# Flowise custom tools
+
+Live exports of the custom tools configured in Flowise
+(`https://botek.easyklima.com`, chatflow `09fc9332-8cdd-4142-8f0e-4259881fc7bf`).
+
+Kept in the repo so the relay's response contracts and the tool code can be
+reviewed together. **Changing a `/tools/*` response shape means updating the
+matching tool here and in Flowise.**
+
+| File | Relay endpoint | Notes |
+|---|---|---|
+| `get_order_data.json` | `POST /tools/get_order_data` | Reads `contact_email:` out of the `[CONTACT_INFO]` block the relay prepends to `question`. Relies on `code:"UNAUTHORIZED"` to drive its e-mail-mismatch retry prompt. |
+| `get_product_data.json` | `POST /tools/get_product_data` | Handles both a single object and an array; filters kits vs single products. |
+| `get_shipping_data.json` | `POST /tools/get_shipping_data` | Calls twice: once without `zoneId` for the zone list, once with it for methods. |
+| `get_payment_methods.json` | `POST /tools/get_payment_methods` | Renders `data[].title`. |
+| `get_product_compatibility.json` | `POST /tools/get_product_compatibility` | Drives brand/model/engine/product disambiguation loops. |
+| `get_car_data.json` | `POST /tools/get_car_data` | Older export; no newer version available from the panel. |
+| `new_ticket.json` | `POST /tools/new_ticket` | Takes `conversationId` **only** from `$flow.sessionId` (`chatwoot:<id>`), never from the model. |
+
+Every tool needs two Flowise variables:
+
+- `$vars.relay_base` — `https://eks-relay.easyklima.com`
+- `$vars.relay_shared_secret` — the relay's `RELAY_SHARED_SECRET`

Datei-Diff unterdrückt, da er zu groß ist
+ 3 - 5
flowise-tools/get_order_data.json


+ 6 - 5
flowise-tools/get_payment_methods.json

@@ -1,8 +1,9 @@
 {
   "name": "get_payment_methods",
-  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nPobierz dostępne metody płatności ze sklepu WooCommerce. Wywołaj gdy użytkownik pyta o metody płatności, sposoby zapłaty, czy można płacić kartą, przelewem, BLIK-iem, gotówką itp. Nigdy nie zgaduj dostępnych metod — zawsze wywołaj to narzędzie.",
-  "color": "linear-gradient(rgb(200,180,100), rgb(180,140,50))",
+  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nPobierz dostępne metody płatności ze sklepu WooCommerce dla właściwej domeny/kraju. Wywołaj gdy użytkownik pyta o metody płatności, sposoby zapłaty, czy można płacić kartą, przelewem, BLIK-iem, gotówką itp. Nigdy nie zgaduj dostępnych metod — zawsze wywołaj to narzędzie.\n\nKRYTYCZNE: Metody płatności różnią się w zależności od kraju/domeny. Nigdy nie zakładaj że wynik dla jednego kraju jest taki sam dla innego — dla każdego nowego kraju/języka wywołaj narzędzie osobno z właściwym parametrem language.\n\nZawsze przekazuj parametr language odpowiedni dla pytanego kraju (kod WPML: pl=Polska, de=Niemcy, sv=Szwecja, nl=Belgia/Holandia, en=angielski itp.) — nie język klienta, lecz język domeny o którą pyta.",
+  "color": "linear-gradient(rgb(239,69,216), rgb(214,41,244))",
   "iconSrc": "",
-  "schema": "[]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({})\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const gateways = data.data\n    if (!Array.isArray(gateways) || !gateways.length) return 'No payment methods available.'\n    const lines = gateways.map(g => '- ' + g.title).join('\\n')\n    return 'Available payment methods:\\n' + lines\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n"
-}
+  "schema": "[{\"id\":0,\"property\":\"language\",\"description\":\"Kod języka WPML wykryty z wiadomości klienta (np. pl, de, en, sv, nl, cs). Wymagany do pobrania metod płatności dla właściwej domeny sklepu.\",\"type\":\"string\",\"required\":false}]",
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\nconst language = typeof $language !== 'undefined' && $language ? String($language) : ''\n\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const body = {}\n  if (language) body.language = language\n\n  const res = await fetch(`${base}/tools/get_payment_methods`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify(body)\n  })\n  const data = await res.json()\n  if (data.ok) {\n    const gateways = data.data\n    if (!Array.isArray(gateways) || !gateways.length) return 'No payment methods available for this region.'\n    const lines = gateways.map(g => '- ' + g.title).join('\\n')\n    return 'Available payment methods:\\n' + lines\n  }\n  return `Info: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n",
+  "workspaceId": "048a397e-3529-48c7-9f65-ae59eb7bd783"
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 2
flowise-tools/get_product_compatibility.json


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
flowise-tools/get_product_data.json


Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 1
flowise-tools/get_shipping_data.json


+ 6 - 5
flowise-tools/new_ticket.json

@@ -1,8 +1,9 @@
 {
   "name": "new_ticket",
-  "description": "IMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nUtwórz ticket supportowy i przekieruj rozmowę do obsługi sklepu. Używaj gdy:\n- klient wyraźnie prosi o kontakt z człowiekiem\n- nie jesteś w stanie rozwiązać problemu samodzielnie\n- baza wiedzy wskazuje że sprawa wymaga kontaktu z supportem\n- minęło dużo czasu od realizacji zamówienia, a paczka nie dotarła\n\nWAŻNE: gdy get_order_data zwróci 'Unauthorized' — NIE zakładaj od razu ticketu. Najpierw poproś klienta o weryfikację numeru zamówienia. Ticket zakładaj dopiero gdy klient potwierdzi dane i nadal nie możesz pomóc, lub wyraźnie poprosi o kontakt z obsługą.\n\nZa każdym razem gdy tworzysz ticket, poinformuj klienta i poproś o cierpliwość — osoba z obsługi skontaktuje się w najbliższym czasie. Przekaż klientowi numer otrzymanego zgłoszenia.",
-  "color": "linear-gradient(rgb(189,154,166), rgb(167,81,91))",
+  "description": "CRITICAL: conversationId is ALWAYS taken automatically from the Chatwoot session — NEVER ask the user for it, NEVER use the order number or any user-provided number as the conversation ID. The conversation ID is an internal system value that the customer has no access to.\n\nIMPORTANT: Always reply to the customer in their own language — the exact language they write in. Never switch to Polish unless the customer writes in Polish.\n\nIMPORTANT: Before creating a ticket for an order-related complaint, call get_order_data first to verify that the customer's email matches the order. Only create a ticket after order ownership is confirmed, or when the customer explicitly requests human support for a non-order issue.\n\nUtwórz ticket supportowy i przekieruj rozmowę do obsługi sklepu. Używaj gdy:\n- klient wyraźnie prosi o kontakt z człowiekiem\n- nie jesteś w stanie rozwiązać problemu samodzielnie\n- baza wiedzy wskazuje że sprawa wymaga kontaktu z supportem\n- minęło dużo czasu od realizacji zamówienia, a paczka nie dotarła\n\nWAŻNE: gdy get_order_data zwróci '[ORDER EMAIL MISMATCH]' — NIE zakładaj od razu ticketu i NIE sugeruj że numer zamówienia jest zły. Zapytaj klienta (w jego języku) o adres email użyty przy składaniu zamówienia. Następnie wywołaj get_order_data ponownie z tym emailem i tym samym numerem zamówienia. Ticket zakładaj dopiero gdy tożsamość klienta zostanie zweryfikowana.\n\nZa każdym razem gdy tworzysz ticket, poinformuj klienta i poproś o cierpliwość — osoba z obsługi skontaktuje się w najbliższym czasie. Przekaż klientowi numer otrzymanego zgłoszenia.",
+  "color": "linear-gradient(rgb(148,32,101), rgb(120,188,81))",
   "iconSrc": "",
-  "schema": "[{\"id\":0,\"property\":\"conversationId\",\"description\":\"ID konwersacji Chatwoot (z overrideConfig.conversationId)\",\"type\":\"number\",\"required\":true},{\"id\":1,\"property\":\"summary\",\"description\":\"Krótkie podsumowanie problemu klienta\",\"type\":\"string\",\"required\":false}]",
-  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\nconst conversationId = typeof $conversationId !== 'undefined' ? Number($conversationId) : Number(($flow && $flow.sessionId || '').replace('chatwoot:',''))\nconst summary = typeof $summary !== 'undefined' ? String($summary) : ''\n\nif (!conversationId || conversationId <= 0) return 'Error: missing conversationId'\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/new_ticket`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ conversationId, summary })\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return `Ticket created: ${data.ticketNumber} (status: ${data.status})`\n  }\n  return `Error creating ticket: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n"
-}
+  "schema": "[{\"id\":0,\"property\":\"summary\",\"description\":\"Krótkie podsumowanie problemu klienta\",\"type\":\"string\",\"required\":false}]",
+  "func": "const fetch = require('node-fetch')\n\nconst base = String(($vars && ($vars.relay_base || $vars.webhook_base)) || 'http://localhost:8080').replace(/\\/$/,'')\nconst secret = String(($vars && $vars.relay_shared_secret) || '')\n\n// CRITICAL: conversationId MUST always come from the Flowise session (chatwoot:<id>).\n// The LLM-provided conversationId parameter is intentionally removed from the schema\n// to prevent the LLM from confusing the order number with the conversation ID.\nconst conversationId = Number(($flow && $flow.sessionId || '').replace('chatwoot:',''))\nconst summary = typeof $summary !== 'undefined' ? String($summary) : ''\n\nif (!conversationId || conversationId <= 0) return 'Error: could not determine Chatwoot conversation ID from session. This is a system error — do not ask the customer for a conversation ID.'\nif (!secret) return 'Error: missing $vars.relay_shared_secret'\n\ntry {\n  const res = await fetch(`${base}/tools/new_ticket`, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${secret}`\n    },\n    body: JSON.stringify({ conversationId, summary })\n  })\n  const data = await res.json()\n  if (data.ok) {\n    return `Ticket created: ${data.ticketNumber} (status: ${data.status})`\n  }\n  return `Error creating ticket: ${data.message || JSON.stringify(data)}`\n} catch (error) {\n  return `Connection error: ${error?.message || String(error)}`\n}\n",
+  "workspaceId": "048a397e-3529-48c7-9f65-ae59eb7bd783"
+}

+ 1946 - 0
package-lock.json

@@ -0,0 +1,1946 @@
+{
+  "name": "eks-relay",
+  "version": "2.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "eks-relay",
+      "version": "2.0.0",
+      "dependencies": {
+        "@prisma/client": "6.19.3",
+        "express": "^5.2.1",
+        "zod": "^4.4.3"
+      },
+      "devDependencies": {
+        "@types/express": "^5.0.0",
+        "@types/node": "^22.10.0",
+        "prisma": "6.19.3",
+        "tsx": "^4.19.2",
+        "typescript": "^5.7.2"
+      },
+      "engines": {
+        "node": ">=22"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+      "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+      "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+      "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+      "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+      "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+      "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+      "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+      "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+      "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+      "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+      "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+      "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+      "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+      "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+      "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+      "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+      "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+      "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+      "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+      "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+      "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+      "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+      "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@prisma/client": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
+      "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "peerDependencies": {
+        "prisma": "*",
+        "typescript": ">=5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "prisma": {
+          "optional": true
+        },
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@prisma/config": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
+      "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "c12": "3.1.0",
+        "deepmerge-ts": "7.1.5",
+        "effect": "3.21.0",
+        "empathic": "2.0.0"
+      }
+    },
+    "node_modules/@prisma/debug": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
+      "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
+      "devOptional": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/engines": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
+      "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
+      "devOptional": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.19.3",
+        "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+        "@prisma/fetch-engine": "6.19.3",
+        "@prisma/get-platform": "6.19.3"
+      }
+    },
+    "node_modules/@prisma/engines-version": {
+      "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+      "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
+      "devOptional": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/@prisma/fetch-engine": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
+      "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.19.3",
+        "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+        "@prisma/get-platform": "6.19.3"
+      }
+    },
+    "node_modules/@prisma/get-platform": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
+      "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/debug": "6.19.3"
+      }
+    },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
+      "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.20.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+      "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+      "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^2.0.0",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.1",
+        "iconv-lite": "^0.7.2",
+        "on-finished": "^2.4.1",
+        "qs": "^6.15.2",
+        "raw-body": "^3.0.2",
+        "type-is": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/body-parser/node_modules/content-type": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+      "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/c12": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+      "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "chokidar": "^4.0.3",
+        "confbox": "^0.2.2",
+        "defu": "^6.1.4",
+        "dotenv": "^16.6.1",
+        "exsolve": "^1.0.7",
+        "giget": "^2.0.0",
+        "jiti": "^2.4.2",
+        "ohash": "^2.0.11",
+        "pathe": "^2.0.3",
+        "perfect-debounce": "^1.0.0",
+        "pkg-types": "^2.2.0",
+        "rc9": "^2.1.2"
+      },
+      "peerDependencies": {
+        "magicast": "^0.3.5"
+      },
+      "peerDependenciesMeta": {
+        "magicast": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+      "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 14.16.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/citty": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+      "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "consola": "^3.2.3"
+      }
+    },
+    "node_modules/confbox": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+      "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/consola": {
+      "version": "3.4.2",
+      "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+      "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+      "devOptional": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.18.0 || >=16.10.0"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deepmerge-ts": {
+      "version": "7.1.5",
+      "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+      "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+      "devOptional": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/defu": {
+      "version": "6.1.7",
+      "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+      "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destr": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+      "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "devOptional": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/effect": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
+      "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.0.0",
+        "fast-check": "^3.23.1"
+      }
+    },
+    "node_modules/empathic": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+      "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+      "devOptional": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+      "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.2",
+        "@esbuild/android-arm": "0.28.2",
+        "@esbuild/android-arm64": "0.28.2",
+        "@esbuild/android-x64": "0.28.2",
+        "@esbuild/darwin-arm64": "0.28.2",
+        "@esbuild/darwin-x64": "0.28.2",
+        "@esbuild/freebsd-arm64": "0.28.2",
+        "@esbuild/freebsd-x64": "0.28.2",
+        "@esbuild/linux-arm": "0.28.2",
+        "@esbuild/linux-arm64": "0.28.2",
+        "@esbuild/linux-ia32": "0.28.2",
+        "@esbuild/linux-loong64": "0.28.2",
+        "@esbuild/linux-mips64el": "0.28.2",
+        "@esbuild/linux-ppc64": "0.28.2",
+        "@esbuild/linux-riscv64": "0.28.2",
+        "@esbuild/linux-s390x": "0.28.2",
+        "@esbuild/linux-x64": "0.28.2",
+        "@esbuild/netbsd-arm64": "0.28.2",
+        "@esbuild/netbsd-x64": "0.28.2",
+        "@esbuild/openbsd-arm64": "0.28.2",
+        "@esbuild/openbsd-x64": "0.28.2",
+        "@esbuild/openharmony-arm64": "0.28.2",
+        "@esbuild/sunos-x64": "0.28.2",
+        "@esbuild/win32-arm64": "0.28.2",
+        "@esbuild/win32-ia32": "0.28.2",
+        "@esbuild/win32-x64": "0.28.2"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/exsolve": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+      "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-check": {
+      "version": "3.23.2",
+      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+      "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+      "devOptional": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "pure-rand": "^6.1.0"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/giget": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+      "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "citty": "^0.1.6",
+        "consola": "^3.4.0",
+        "defu": "^6.1.4",
+        "node-fetch-native": "^1.6.6",
+        "nypm": "^0.6.0",
+        "pathe": "^2.0.3"
+      },
+      "bin": {
+        "giget": "dist/cli.mjs"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+      "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+      "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/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/jiti": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+      "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+      "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-fetch-native": {
+      "version": "1.6.7",
+      "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+      "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/nypm": {
+      "version": "0.6.9",
+      "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz",
+      "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "citty": "^0.2.2",
+        "pathe": "^2.0.3",
+        "tinyexec": "^1.2.4"
+      },
+      "bin": {
+        "nypm": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/nypm/node_modules/citty": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+      "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/ohash": {
+      "version": "2.0.12",
+      "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
+      "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/perfect-debounce": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+      "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+      "devOptional": true,
+      "license": "MIT"
+    },
+    "node_modules/pkg-types": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+      "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "confbox": "^0.2.4",
+        "exsolve": "^1.0.8",
+        "pathe": "^2.0.3"
+      }
+    },
+    "node_modules/prisma": {
+      "version": "6.19.3",
+      "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
+      "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
+      "devOptional": true,
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@prisma/config": "6.19.3",
+        "@prisma/engines": "6.19.3"
+      },
+      "bin": {
+        "prisma": "build/index.js"
+      },
+      "engines": {
+        "node": ">=18.18"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pure-rand": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+      "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+      "devOptional": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+      "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/rc9": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+      "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+      "devOptional": true,
+      "license": "MIT",
+      "dependencies": {
+        "defu": "^6.1.4",
+        "destr": "^2.0.3"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+      "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+      "devOptional": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.18.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/tinyexec": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+      "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+      "devOptional": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.23.12",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
+      "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+      "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/zod": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+      "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    }
+  }
+}

+ 35 - 0
package.json

@@ -0,0 +1,35 @@
+{
+  "name": "eks-relay",
+  "version": "2.0.0",
+  "private": true,
+  "description": "EKS Support Relay - integration hub between Chatwoot, Flowise, WooCommerce and the WordPress Store API",
+  "type": "module",
+  "engines": {
+    "node": ">=22"
+  },
+  "scripts": {
+    "build": "tsc -p tsconfig.json",
+    "start": "node dist/index.js",
+    "dev": "tsx watch src/index.ts",
+    "test": "tsx --test \"tests/**/*.test.ts\"",
+    "lint": "tsc -p tsconfig.check.json",
+    "db:generate": "prisma generate",
+    "db:migrate": "prisma migrate deploy",
+    "db:migrate:dev": "prisma migrate dev",
+    "db:studio": "prisma studio --port 5555 --hostname 127.0.0.1",
+    "db:validate": "prisma validate",
+    "events": "tsx scripts/events.ts"
+  },
+  "dependencies": {
+    "@prisma/client": "6.19.3",
+    "express": "^5.2.1",
+    "zod": "^4.4.3"
+  },
+  "devDependencies": {
+    "@types/express": "^5.0.0",
+    "@types/node": "^22.10.0",
+    "prisma": "6.19.3",
+    "tsx": "^4.19.2",
+    "typescript": "^5.7.2"
+  }
+}

+ 71 - 0
prisma/migrations/20260820115241_init/migration.sql

@@ -0,0 +1,71 @@
+-- CreateTable
+CREATE TABLE "ProcessedMessage" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "source" TEXT NOT NULL,
+    "messageId" TEXT NOT NULL,
+    "conversationId" INTEGER NOT NULL,
+    "status" TEXT NOT NULL,
+    "reason" TEXT,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "Job" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "type" TEXT NOT NULL,
+    "status" TEXT NOT NULL DEFAULT 'queued',
+    "attempts" INTEGER NOT NULL DEFAULT 0,
+    "maxAttempts" INTEGER NOT NULL DEFAULT 3,
+    "payloadJson" TEXT NOT NULL,
+    "lastError" TEXT,
+    "runAfter" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "startedAt" DATETIME,
+    "finishedAt" DATETIME,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "Ticket" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "conversationId" INTEGER NOT NULL,
+    "ticketNumber" TEXT NOT NULL,
+    "reason" TEXT,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateTable
+CREATE TABLE "AuditEvent" (
+    "id" TEXT NOT NULL PRIMARY KEY,
+    "conversationId" INTEGER,
+    "messageId" TEXT,
+    "eventType" TEXT NOT NULL,
+    "summary" TEXT NOT NULL,
+    "metaJson" TEXT,
+    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- CreateIndex
+CREATE INDEX "ProcessedMessage_conversationId_idx" ON "ProcessedMessage"("conversationId");
+
+-- CreateIndex
+CREATE INDEX "ProcessedMessage_status_idx" ON "ProcessedMessage"("status");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ProcessedMessage_source_messageId_key" ON "ProcessedMessage"("source", "messageId");
+
+-- CreateIndex
+CREATE INDEX "Job_status_runAfter_idx" ON "Job"("status", "runAfter");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Ticket_conversationId_key" ON "Ticket"("conversationId");
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_conversationId_idx" ON "AuditEvent"("conversationId");
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_eventType_idx" ON "AuditEvent"("eventType");
+
+-- CreateIndex
+CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt");

+ 3 - 0
prisma/migrations/migration_lock.toml

@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"

+ 80 - 0
prisma/schema.prisma

@@ -0,0 +1,80 @@
+// EKS Support Relay — persistence layer.
+// SQLite for now; the schema deliberately avoids SQLite-only features so a
+// later move to PostgreSQL only needs a datasource/provider change.
+
+generator client {
+  provider = "prisma-client-js"
+  output   = "../node_modules/.prisma/client"
+}
+
+datasource db {
+  provider = "sqlite"
+  url      = env("DATABASE_URL")
+}
+
+/// One row per inbound source message. Primary idempotency guard for the
+/// Chatwoot webhook: (source, messageId) is unique.
+model ProcessedMessage {
+  id             String   @id @default(cuid())
+  source         String
+  messageId      String
+  conversationId Int
+  /// queued | processing | replied | skipped | ticket | failed | spam
+  status         String
+  reason         String?
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  @@unique([source, messageId])
+  @@index([conversationId])
+  @@index([status])
+}
+
+/// Async work item. The webhook persists a job and returns 202; the in-process
+/// worker picks it up, with retries and exponential backoff.
+model Job {
+  id          String    @id @default(cuid())
+  /// chatwoot_message
+  type        String
+  /// queued | processing | done | failed | dead
+  status      String    @default("queued")
+  attempts    Int       @default(0)
+  maxAttempts Int       @default(3)
+  /// Redacted, minimal payload — never full secrets.
+  payloadJson String
+  /// Redacted error text.
+  lastError   String?
+  runAfter    DateTime  @default(now())
+  startedAt   DateTime?
+  finishedAt  DateTime?
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  @@index([status, runAfter])
+}
+
+/// Handoff record. conversationId is unique so repeated new_ticket calls for
+/// the same conversation always resolve to the same ticket number.
+model Ticket {
+  id             String   @id @default(cuid())
+  conversationId Int      @unique
+  ticketNumber   String
+  reason         String?
+  createdAt      DateTime @default(now())
+}
+
+/// Non-secret audit trail of decisions taken per message/conversation.
+model AuditEvent {
+  id             String   @id @default(cuid())
+  conversationId Int?
+  messageId      String?
+  eventType      String
+  summary        String
+  /// Redacted JSON metadata.
+  metaJson       String?
+  createdAt      DateTime @default(now())
+
+  @@index([conversationId])
+  @@index([eventType])
+  @@index([createdAt])
+}

+ 0 - 66
public/index.php

@@ -1,66 +0,0 @@
-<?php
-
-declare(strict_types=1);
-header('X-EKSRelay: hit');
-file_put_contents(__DIR__ . '/../logs/_probe.txt', "probe " . date('c') . "\n", FILE_APPEND);
-
-
-/**
- * EKSRelay – single entry point.
- *
- * Run: php -S 0.0.0.0:8080 -t public
- */
-
-require_once __DIR__ . '/../vendor/autoload.php';
-
-use EKSRelay\Core\Env;
-use EKSRelay\Core\Logger;
-use EKSRelay\Core\Router;
-use EKSRelay\Handlers\ChatwootWebhookHandler;
-use EKSRelay\Handlers\NewTicketHandler;
-use EKSRelay\Handlers\WooToolsHandler;
-
-// ── Bootstrap ──────────────────────────────────────────────────────
-Env::load(__DIR__ . '/../.env');
-Logger::init();
-
-// ── Routes ─────────────────────────────────────────────────────────
-$router = new Router();
-
-// Chatwoot webhook
-$router->post('/webhooks/chatwoot', [ChatwootWebhookHandler::class, 'handle']);
-
-// Tools (called by Flowise or external)
-$router->post('/tools/new_ticket',             [NewTicketHandler::class, 'handle']);
-$router->post('/tools/get_order_data',         [WooToolsHandler::class, 'getOrderData']);
-$router->post('/tools/get_product_data',       [WooToolsHandler::class, 'getProductData']);
-$router->post('/tools/get_shipping_data',      [WooToolsHandler::class, 'getShippingData']);
-$router->post('/tools/get_payment_methods',    [WooToolsHandler::class, 'getPaymentMethods']);
-$router->post('/tools/get_product_compatibility', [WooToolsHandler::class, 'getProductCompatibility']);
-$router->post('/tools/get_car_data',           [WooToolsHandler::class, 'getCarData']);
-
-// ── Dispatch ───────────────────────────────────────────────────────
-$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
-
-$path = $_GET['__path']
-    ?? (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
-
-$path = '/' . trim((string)$path, '/');
-
-$rawBody = file_get_contents('php://input') ?: '';
-
-Logger::info('Incoming request', [
-    'method' => $method,
-    'uri'    => $_SERVER['REQUEST_URI'] ?? null,
-    'ip'     => $_SERVER['REMOTE_ADDR'] ?? null,
-    'ua'     => $_SERVER['HTTP_USER_AGENT'] ?? null,
-]);
-
-if ($rawBody !== '') {
-    // UWAGA: czasem payload jest duży; możesz obciąć do np. 50k
-    Logger::debug('Incoming body', [
-        'body' => json_decode($rawBody, true) ?? $rawBody
-    ]);
-}
-
-$router->dispatch($method, $path);

+ 50 - 0
scripts/events.ts

@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * CLI view of the audit trail — the offline equivalent of GET /admin/events.
+ *
+ *   npm run events -- --limit 20
+ *   npm run events -- --conversation 1311
+ *   npm run events -- --jobs
+ */
+import { db, disconnectDb } from '../src/store/db.js';
+
+function arg(name: string): string | undefined {
+  const i = process.argv.indexOf(`--${name}`);
+  return i >= 0 ? process.argv[i + 1] : undefined;
+}
+
+async function main(): Promise<void> {
+  const limit = Number(arg('limit') ?? 30);
+  const conversationId = arg('conversation') ? Number(arg('conversation')) : undefined;
+
+  if (process.argv.includes('--jobs')) {
+    const jobs = await db().job.findMany({ orderBy: { updatedAt: 'desc' }, take: limit });
+    for (const j of jobs) {
+      console.log(
+        `${j.updatedAt.toISOString()}  ${j.status.padEnd(10)} attempts=${j.attempts}  ${j.type}` +
+          (j.lastError ? `\n    error: ${j.lastError}` : ''),
+      );
+    }
+    return;
+  }
+
+  const events = await db().auditEvent.findMany({
+    where: conversationId ? { conversationId } : undefined,
+    orderBy: { createdAt: 'desc' },
+    take: limit,
+  });
+
+  for (const e of events) {
+    console.log(
+      `${e.createdAt.toISOString()}  conv=${e.conversationId ?? '-'}  ${e.eventType.padEnd(22)} ${e.summary}`,
+    );
+  }
+  console.log(`\n${events.length} event(s).`);
+}
+
+main()
+  .catch((err: unknown) => {
+    console.error(err instanceof Error ? err.message : String(err));
+    process.exitCode = 1;
+  })
+  .finally(() => void disconnectDb());

+ 0 - 193
src/Clients/ChatwootClient.php

@@ -1,193 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Clients;
-
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpClient;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-
-final class ChatwootClient
-{
-    private string $baseUrl;
-    private string $token;
-    private int $accountId;
-
-    public function __construct()
-    {
-        $this->baseUrl   = rtrim(Env::get('CHATWOOT_BASE_URL'), '/');
-        $this->token     = Env::get('CHATWOOT_API_TOKEN');
-        $this->accountId = Env::getInt('CHATWOOT_ACCOUNT_ID', 1);
-    }
-
-    // ---------------------------------------------------------------
-    // API helpers
-    // ---------------------------------------------------------------
-
-    private function apiUrl(string $path): string
-    {
-        // Chatwoot v2/v3 API: /api/v1/accounts/{account_id}/...
-        return "{$this->baseUrl}/api/v1/accounts/{$this->accountId}{$path}";
-    }
-
-    private function authHeaders(): array
-    {
-        return ["api_access_token: {$this->token}"];
-    }
-
-    /**
-     * @return array Decoded JSON response
-     */
-    private function api(string $method, string $path, ?array $body = null): array
-    {
-        $url = $this->apiUrl($path);
-        $res = HttpClient::request($method, $url, $this->authHeaders(), $body);
-
-        if ($res['status'] >= 400) {
-            Logger::warn("Chatwoot API error", [
-                'method' => $method,
-                'path'   => $path,
-                'status' => $res['status'],
-                'body'   => mb_substr($res['body'], 0, 500),
-            ]);
-            throw new HttpException(502, 'CHATWOOT_API_ERROR', "Chatwoot returned HTTP {$res['status']}");
-        }
-
-        return $res['json'] ?? [];
-    }
-
-    // ---------------------------------------------------------------
-    // Public methods
-    // ---------------------------------------------------------------
-
-    /**
-     * Get full conversation details (labels, custom_attributes, etc.).
-     */
-    public function getConversation(int $conversationId): array
-    {
-        return $this->api('GET', "/conversations/{$conversationId}");
-    }
-
-    /**
-     * Add a label to a conversation.
-     * Chatwoot API: POST /conversations/{id}/labels  (Chatwoot >= v2.14)
-     * The endpoint expects { "labels": ["label1","label2"] } and REPLACES all labels,
-     * so we first fetch existing labels and merge.
-     *
-     * NOTE: Chatwoot label API behaviour may vary across versions.
-     * In v3.x the endpoint path/format is the same, but verify if you upgrade.
-     */
-    public function addLabel(int $conversationId, string $label): void
-    {
-        $conv = $this->getConversation($conversationId);
-        $existing = $conv['labels'] ?? [];
-        if (in_array($label, $existing, true)) {
-            return; // already present
-        }
-        $existing[] = $label;
-
-        // Chatwoot expects a JSON body with the complete labels array
-        $this->api('POST', "/conversations/{$conversationId}/labels", [
-            'labels' => $existing,
-        ]);
-
-        Logger::info("Label added", ['conversation_id' => $conversationId, 'label' => $label]);
-    }
-
-    /**
-     * Set custom attributes on a conversation.
-     * POST /conversations/{id}/custom_attributes with { "custom_attributes": { ... } }
-     *
-     * Chatwoot merges provided keys into existing custom_attributes.
-     */
-    public function setCustomAttributes(int $conversationId, array $attrs): void
-    {
-        $result = $this->api('POST', "/conversations/{$conversationId}/custom_attributes", [
-            'custom_attributes' => $attrs,
-        ]);
-        Logger::info("Custom attributes set", ['conversation_id' => $conversationId, 'attrs' => $attrs]);
-        Logger::debug("Custom attributes API response", [
-            'conversation_id'       => $conversationId,
-            'returned_custom_attrs' => $result['custom_attributes'] ?? '(missing)',
-        ]);
-    }
-
-    /**
-     * Assign a specific agent to a conversation.
-     * POST /conversations/{id}/assignments with { "assignee_id": agent_id }
-     */
-    public function assignConversation(int $conversationId, int $agentId): void
-    {
-        $this->api('POST', "/conversations/{$conversationId}/assignments", [
-            'assignee_id' => $agentId,
-        ]);
-        Logger::info("Conversation assigned", ['conversation_id' => $conversationId, 'agent_id' => $agentId]);
-    }
-
-    /**
-     * Remove the assigned agent from a conversation (un-assign).
-     *
-     * Chatwoot API: POST /conversations/{id}/assignments
-     * with { "assignee_id": null } to un-assign.
-     *
-     * NOTE (Chatwoot version): In v2.x/v3.x the assignments endpoint accepts
-     * assignee_id=null to clear the assignment. If your version behaves
-     * differently, adjust accordingly.
-     */
-    public function unassignConversation(int $conversationId): void
-    {
-        // Attempt to un-assign by setting assignee_id to null
-        $url = $this->apiUrl("/conversations/{$conversationId}/assignments");
-        $res = HttpClient::request('POST', $url, $this->authHeaders(), [
-            'assignee_id' => null,
-        ]);
-
-        if ($res['status'] >= 400) {
-            Logger::warn("Unassign may have failed", [
-                'conversation_id' => $conversationId,
-                'status'          => $res['status'],
-            ]);
-        } else {
-            Logger::info("Conversation unassigned", ['conversation_id' => $conversationId]);
-        }
-    }
-
-    /**
-     * Send an outgoing message in a conversation.
-     * POST /conversations/{id}/messages
-     *
-     * message_type: 1 = outgoing
-     */
-    public function sendOutgoingMessage(int $conversationId, string $text): array
-    {
-        return $this->api('POST', "/conversations/{$conversationId}/messages", [
-            'content'      => $text,
-            'message_type' => 'outgoing',
-            'private'      => false,
-        ]);
-    }
-
-    /**
-     * Check if conversation is in "ticket/manual" mode.
-     * Returns true if label=ticket or custom_attributes.handoff=true.
-     */
-    public function isTicketMode(int $conversationId, ?array $convData = null): bool
-    {
-        $conv = $convData ?? $this->getConversation($conversationId);
-
-        $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket');
-        $labels = $conv['labels'] ?? [];
-        if (in_array($ticketLabel, $labels, true)) {
-            return true;
-        }
-
-        $ca = $conv['custom_attributes'] ?? [];
-        if (isset($ca['handoff']) && ($ca['handoff'] === true || $ca['handoff'] === 'true')) {
-            return true;
-        }
-
-        return false;
-    }
-}

+ 0 - 88
src/Clients/FlowiseClient.php

@@ -1,88 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Clients;
-
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpClient;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-
-final class FlowiseClient
-{
-    private string $predictUrl;
-    private string $apiKey;
-
-    public function __construct()
-    {
-        $this->predictUrl = Env::get('FLOWISE_PREDICT_URL');
-        $this->apiKey     = Env::get('FLOWISE_API_KEY');
-    }
-
-    /**
-     * Call Flowise prediction endpoint and return a normalised response.
-     *
-     * @param array $payload Full request body (question, overrideConfig, metadata, …)
-     * @return array{type: string, text: string|null, actions: array|null}
-     *   type = "reply" | "handoff" | "unknown"
-     */
-    public function predict(array $payload): array
-    {
-        $headers = [];
-        if ($this->apiKey !== '') {
-            $headers[] = "Authorization: Bearer {$this->apiKey}";
-        }
-
-        Logger::info('Calling Flowise', ['url' => $this->predictUrl]);
-
-        $res = HttpClient::request('POST', $this->predictUrl, $headers, $payload, 60);
-
-        if ($res['status'] >= 400) {
-            Logger::error('Flowise error', ['status' => $res['status'], 'body' => mb_substr($res['body'], 0, 500)]);
-            throw new HttpException(502, 'FLOWISE_ERROR', "Flowise returned HTTP {$res['status']}");
-        }
-
-        return $this->normalise($res);
-    }
-
-    /**
-     * Normalise various Flowise response formats into a predictable structure.
-     *
-     * Flowise may return:
-     *  - plain text string (the body IS the answer)
-     *  - JSON { "text": "...", "question": "..." }
-     *  - JSON with actions: { "text": "...", "actions": [ { "type": "handoff", ... } ] }
-     */
-    private function normalise(array $res): array
-    {
-        $json = $res['json'];
-        $body = trim($res['body']);
-
-        // Case 1: JSON object
-        if (is_array($json)) {
-            $text    = $json['text'] ?? $json['response'] ?? $json['answer'] ?? null;
-            $actions = $json['actions'] ?? null;
-
-            // Detect handoff in actions
-            if (is_array($actions)) {
-                foreach ($actions as $action) {
-                    if (isset($action['type']) && $action['type'] === 'handoff') {
-                        return ['type' => 'handoff', 'text' => $text, 'actions' => $actions];
-                    }
-                }
-            }
-
-            if ($text !== null) {
-                return ['type' => 'reply', 'text' => (string)$text, 'actions' => $actions];
-            }
-        }
-
-        // Case 2: plain text
-        if ($body !== '') {
-            return ['type' => 'reply', 'text' => $body, 'actions' => null];
-        }
-
-        return ['type' => 'unknown', 'text' => null, 'actions' => null];
-    }
-}

+ 0 - 298
src/Clients/WooCommerceClient.php

@@ -1,298 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Clients;
-
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpClient;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-
-/**
- * WooCommerce REST API client.
- * Uses Consumer Key / Consumer Secret authentication (query-string method for HTTPS).
- */
-final class WooCommerceClient
-{
-    private string $baseUrl;
-    private string $ck;
-    private string $cs;
-
-    public function __construct()
-    {
-        $this->baseUrl = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/');
-        $this->ck      = Env::get('WOOCOMMERCE_CONSUMER_KEY');
-        $this->cs      = Env::get('WOOCOMMERCE_CONSUMER_SECRET');
-    }
-
-    // ---------------------------------------------------------------
-    // Internal helpers
-    // ---------------------------------------------------------------
-
-    private function url(string $endpoint, array $params = []): string
-    {
-        $params['consumer_key']    = $this->ck;
-        $params['consumer_secret'] = $this->cs;
-
-        return $this->baseUrl . '/wp-json/wc/v3/' . ltrim($endpoint, '/') . '?' . http_build_query($params);
-    }
-
-    /**
-     * @return array Decoded JSON
-     */
-    private function get(string $endpoint, array $params = []): array
-    {
-        $url = $this->url($endpoint, $params);
-        $res = HttpClient::request('GET', $url);
-
-        if ($res['status'] >= 400) {
-            Logger::warn('WooCommerce API error', [
-                'endpoint' => $endpoint,
-                'status'   => $res['status'],
-                'body'     => mb_substr($res['body'], 0, 500),
-            ]);
-            throw new HttpException(502, 'WOOCOMMERCE_API_ERROR', "WooCommerce returned HTTP {$res['status']}");
-        }
-
-        return $res['json'] ?? [];
-    }
-
-    // ---------------------------------------------------------------
-    // Orders
-    // ---------------------------------------------------------------
-
-    /**
-     * Find order by order number or by customer email.
-     * Priority: orderNumber > email.
-     */
-    public function getOrder(?string $orderNumber = null, ?string $email = null, string $currency = ''): array
-    {
-        $currencyParam = $currency !== '' ? ['currency' => $currency] : [];
-
-        if ($orderNumber !== null && $orderNumber !== '') {
-            // WooCommerce stores order number as the post ID by default.
-            // Try direct fetch first.
-            try {
-                return $this->get("orders/{$orderNumber}", $currencyParam);
-            } catch (HttpException) {
-                // If direct fetch fails (e.g. custom order numbers plugin),
-                // fall back to search.
-                $results = $this->get('orders', array_merge(['search' => $orderNumber, 'per_page' => 1], $currencyParam));
-                if (!empty($results)) {
-                    return $results[0];
-                }
-                throw new HttpException(404, 'ORDER_NOT_FOUND', "Order #{$orderNumber} not found.");
-            }
-        }
-
-        if ($email !== null && $email !== '') {
-            $results = $this->get('orders', array_merge(['search' => $email, 'per_page' => 5, 'orderby' => 'date', 'order' => 'desc'], $currencyParam));
-            if (empty($results)) {
-                throw new HttpException(404, 'ORDER_NOT_FOUND', "No orders found for email {$email}.");
-            }
-            return $results[0]; // most recent
-        }
-
-        throw new HttpException(400, 'MISSING_PARAMS', 'Provide orderNumber or email.');
-    }
-
-    /**
-     * Get multiple orders by email (for listing).
-     */
-    public function getOrdersByEmail(string $email, int $limit = 5, string $currency = ''): array
-    {
-        $params = ['search' => $email, 'per_page' => $limit, 'orderby' => 'date', 'order' => 'desc'];
-        if ($currency !== '') {
-            $params['currency'] = $currency;
-        }
-        return $this->get('orders', $params);
-    }
-
-    // ---------------------------------------------------------------
-    // Products
-    // ---------------------------------------------------------------
-
-    /**
-     * Get product by ID or by SKU.
-     * $lang — WPML language code (e.g. 'de', 'pl'), passed as ?lang= to get translated content.
-     */
-    public function getProduct(?int $productId = null, ?string $sku = null, string $currency = '', string $lang = ''): array
-    {
-        $params = [];
-        if ($currency !== '') $params['currency'] = $currency;
-        if ($lang !== '')     $params['lang']     = $lang;
-
-        if ($productId !== null) {
-            $product = $this->get("products/{$productId}", $params);
-            if (($product['status'] ?? '') !== 'publish' || ($product['catalog_visibility'] ?? '') === 'hidden') {
-                throw new HttpException(404, 'PRODUCT_NOT_FOUND', "Product #{$productId} is not available.");
-            }
-            return $product;
-        }
-
-        if ($sku !== null && $sku !== '') {
-            $results = $this->get('products', array_merge(['sku' => $sku, 'per_page' => 1, 'status' => 'publish'], $params));
-            if (empty($results)) {
-                throw new HttpException(404, 'PRODUCT_NOT_FOUND', "Product with SKU {$sku} not found.");
-            }
-            return $results[0];
-        }
-
-        throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId or sku.');
-    }
-
-    /**
-     * Search products by name/keyword.
-     * $lang — WPML language code (e.g. 'de', 'pl'), passed as ?lang= to search within the
-     * translated product index and return translated content.
-     */
-    public function searchProducts(string $query, int $limit = 5, string $currency = '', string $lang = ''): array
-    {
-        $params = ['search' => $query, 'per_page' => $limit, 'status' => 'publish'];
-        if ($currency !== '') $params['currency'] = $currency;
-        if ($lang !== '')     $params['lang']     = $lang;
-        $results = $this->get('products', $params);
-
-        // WPML may return empty results when the default language is explicitly
-        // requested via ?lang=XX. Retry without lang so WPML uses the default.
-        if (empty($results) && $lang !== '') {
-            $fallback = ['search' => $query, 'per_page' => $limit, 'status' => 'publish'];
-            if ($currency !== '') $fallback['currency'] = $currency;
-            $results = $this->get('products', $fallback);
-        }
-
-        // Cross-language fallback: the search term may be in a different language than the
-        // store default (e.g. user saw product names in German, now chats in Polish).
-        // Use WPML's lang=all to search across ALL language versions simultaneously —
-        // this is language-agnostic and works regardless of how many languages the store has.
-        // After finding matching products, re-fetch each one by SKU in the target language
-        // (SKUs are identical across all translations in WooCommerce + WPML).
-        if (empty($results) && $lang !== '') {
-            $allFound = [];
-            try {
-                $allParams = ['search' => $query, 'per_page' => $limit, 'status' => 'publish', 'lang' => 'all'];
-                if ($currency !== '') $allParams['currency'] = $currency;
-                $allFound = $this->get('products', $allParams);
-            } catch (\Throwable) { /* lang=all not supported by this WPML version — skip */ }
-
-            if (!empty($allFound)) {
-                // Re-fetch each found product in the target language using its SKU.
-                // Deduplicate by SKU to avoid returning multiple language versions of the same product.
-                $translated = [];
-                $seenSkus   = [];
-                foreach ($allFound as $foundProduct) {
-                    $sku = trim((string)($foundProduct['sku'] ?? ''));
-                    if ($sku === '') {
-                        // No SKU — keep the found version as-is (can't re-fetch reliably)
-                        $translated[] = $foundProduct;
-                        continue;
-                    }
-                    if (isset($seenSkus[$sku])) continue; // already processed this product
-                    $seenSkus[$sku] = true;
-                    try {
-                        $skuParams = ['sku' => $sku, 'per_page' => 1, 'status' => 'publish'];
-                        if ($lang !== '')     $skuParams['lang']     = $lang;
-                        if ($currency !== '') $skuParams['currency'] = $currency;
-                        $skuResult  = $this->get('products', $skuParams);
-                        $translated[] = !empty($skuResult) ? $skuResult[0] : $foundProduct;
-                    } catch (\Throwable) {
-                        $translated[] = $foundProduct; // re-fetch failed — keep found version
-                    }
-                }
-                if (!empty($translated)) {
-                    $results = $translated;
-                }
-            }
-        }
-
-        // Exclude hidden products (catalog_visibility=hidden).
-        $results = array_values(array_filter($results, fn($p) => ($p['catalog_visibility'] ?? '') !== 'hidden'));
-
-        return $results;
-    }
-
-    // ---------------------------------------------------------------
-    // Shipping
-    // ---------------------------------------------------------------
-
-    /**
-     * Get shipping zones and methods.
-     * WooCommerce REST API provides: GET /shipping/zones and
-     * GET /shipping/zones/{zone_id}/methods
-     */
-    public function getShippingZones(): array
-    {
-        return $this->get('shipping/zones');
-    }
-
-    public function getShippingMethods(int $zoneId, string $currency = ''): array
-    {
-        $params = $currency !== '' ? ['currency' => $currency] : [];
-        return $this->get("shipping/zones/{$zoneId}/methods", $params);
-    }
-
-    /**
-     * Get locations (country codes) for a shipping zone.
-     * Returns array of objects with 'code' (e.g. 'PL', 'DE') and 'type' ('country'|'state'|'continent').
-     * Zone 0 (Rest of the World) typically returns an empty array.
-     */
-    public function getShippingZoneLocations(int $zoneId): array
-    {
-        try {
-            return $this->get("shipping/zones/{$zoneId}/locations");
-        } catch (HttpException) {
-            return [];
-        }
-    }
-
-    /**
-     * Batch-fetch products by IDs.
-     * Used to retrieve translated titles/prices after finding product IDs in a different language context.
-     * $lang — WPML language code, returned titles will be in that language (falls back to default if no translation).
-     */
-    public function getProductsByIds(array $ids, string $lang = '', string $currency = ''): array
-    {
-        if (empty($ids)) return [];
-        $params = [
-            'include'  => implode(',', array_map('intval', $ids)),
-            'per_page' => min(count($ids), 100),
-            'status'   => 'publish',
-        ];
-        if ($lang !== '')     $params['lang']     = $lang;
-        if ($currency !== '') $params['currency'] = $currency;
-        return $this->get('products', $params);
-    }
-
-    /**
-     * Get all shipping info (zones + methods per zone).
-     */
-    public function getAllShippingData(): array
-    {
-        $zones = $this->getShippingZones();
-        $result = [];
-        foreach ($zones as $zone) {
-            $zid = (int)($zone['id'] ?? 0);
-            $methods = $this->getShippingMethods($zid);
-            $result[] = [
-                'zone'    => $zone,
-                'methods' => $methods,
-            ];
-        }
-        return $result;
-    }
-
-    // ---------------------------------------------------------------
-    // Payment gateways
-    // ---------------------------------------------------------------
-
-    /**
-     * Get payment gateways.
-     * WooCommerce REST API: GET /payment_gateways
-     * NOTE: This endpoint requires admin-level consumer keys.
-     */
-    public function getPaymentGateways(): array
-    {
-        return $this->get('payment_gateways');
-    }
-}

+ 0 - 27
src/Core/Auth.php

@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-final class Auth
-{
-    /**
-     * Verify Bearer token against RELAY_SHARED_SECRET.
-     * Throws HttpException(401) on failure.
-     */
-    public static function requireBearer(): void
-    {
-        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
-        if (!str_starts_with($header, 'Bearer ')) {
-            throw new HttpException(401, 'UNAUTHORIZED', 'Missing or invalid Authorization header.');
-        }
-
-        $token = substr($header, 7);
-        $secret = Env::get('RELAY_SHARED_SECRET');
-
-        if ($secret === '' || !hash_equals($secret, $token)) {
-            throw new HttpException(401, 'UNAUTHORIZED', 'Invalid shared secret.');
-        }
-    }
-}

+ 0 - 71
src/Core/Env.php

@@ -1,71 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-/**
- * Minimal .env file loader. Reads KEY=VALUE lines, supports # comments and
- * double-quoted values. Does NOT overwrite existing env vars.
- */
-final class Env
-{
-    private static bool $loaded = false;
-
-    public static function load(string $path): void
-    {
-        if (self::$loaded) {
-            return;
-        }
-
-        if (!is_file($path)) {
-            return;
-        }
-
-        $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
-        if ($lines === false) {
-            return;
-        }
-
-        foreach ($lines as $line) {
-            $line = trim($line);
-            if ($line === '' || str_starts_with($line, '#')) {
-                continue;
-            }
-            $eqPos = strpos($line, '=');
-            if ($eqPos === false) {
-                continue;
-            }
-            $key = trim(substr($line, 0, $eqPos));
-            $value = trim(substr($line, $eqPos + 1));
-
-            // Strip surrounding quotes
-            if (strlen($value) >= 2 && $value[0] === '"' && str_ends_with($value, '"')) {
-                $value = substr($value, 1, -1);
-            }
-
-            // Don't overwrite existing env vars
-            if (getenv($key) === false && !isset($_ENV[$key])) {
-                putenv("{$key}={$value}");
-                $_ENV[$key] = $value;
-            }
-        }
-
-        self::$loaded = true;
-    }
-
-    public static function get(string $key, string $default = ''): string
-    {
-        $val = getenv($key);
-        if ($val !== false && $val !== '') {
-            return $val;
-        }
-        return $_ENV[$key] ?? $default;
-    }
-
-    public static function getInt(string $key, int $default = 0): int
-    {
-        $val = self::get($key);
-        return $val !== '' ? (int)$val : $default;
-    }
-}

+ 0 - 90
src/Core/HttpClient.php

@@ -1,90 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-/**
- * Thin cURL wrapper for JSON APIs.
- */
-final class HttpClient
-{
-    /**
-     * @return array{status: int, body: string, json: mixed}
-     */
-    public static function request(
-        string $method,
-        string $url,
-        array $headers = [],
-        ?array $jsonBody = null,
-        int $timeoutSeconds = 30,
-    ): array {
-        $ch = curl_init();
-
-        $opts = [
-            CURLOPT_URL            => $url,
-            CURLOPT_RETURNTRANSFER => true,
-            CURLOPT_TIMEOUT        => $timeoutSeconds,
-            CURLOPT_CONNECTTIMEOUT => 10,
-            CURLOPT_FOLLOWLOCATION => true,
-            CURLOPT_MAXREDIRS      => 3,
-        ];
-
-        // Use bundled CA certs if PHP/system doesn't have them configured
-        $caBundle = Env::get('CURL_CA_BUNDLE');
-        if ($caBundle === '') {
-            // Common fallback locations
-            foreach ([
-                dirname(__DIR__, 2) . '/cacert.pem',
-                (getenv('APPDATA') ?: '') . '/php/cacert.pem',
-            ] as $candidate) {
-                if ($candidate !== '' && is_file($candidate)) {
-                    $caBundle = $candidate;
-                    break;
-                }
-            }
-        }
-        if ($caBundle !== '') {
-            $opts[CURLOPT_CAINFO] = $caBundle;
-        }
-
-        curl_setopt_array($ch, $opts);
-
-        $headers[] = 'Accept: application/json';
-
-        if ($jsonBody !== null) {
-            $encoded = json_encode($jsonBody, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
-            curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
-            $headers[] = 'Content-Type: application/json';
-        }
-
-        $method = strtoupper($method);
-        match ($method) {
-            'GET'    => null,
-            'POST'   => curl_setopt($ch, CURLOPT_POST, true),
-            'PUT'    => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'),
-            'PATCH'  => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'),
-            'DELETE' => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'),
-            default  => curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method),
-        };
-
-        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
-
-        $body   = curl_exec($ch);
-        $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
-        $error  = curl_error($ch);
-        curl_close($ch);
-
-        if ($body === false) {
-            throw new HttpException(502, 'UPSTREAM_ERROR', "cURL error: {$error}");
-        }
-
-        $json = json_decode((string)$body, true);
-
-        return [
-            'status' => $status,
-            'body'   => (string)$body,
-            'json'   => $json,
-        ];
-    }
-}

+ 0 - 30
src/Core/HttpException.php

@@ -1,30 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-final class HttpException extends \RuntimeException
-{
-    public function __construct(
-        public readonly int $httpCode,
-        public readonly string $errorCode,
-        string $message,
-        public readonly ?array $details = null,
-    ) {
-        parent::__construct($message, $httpCode);
-    }
-
-    public function toArray(): array
-    {
-        $out = [
-            'ok'      => false,
-            'code'    => $this->errorCode,
-            'message' => $this->getMessage(),
-        ];
-        if ($this->details !== null) {
-            $out['details'] = $this->details;
-        }
-        return $out;
-    }
-}

+ 0 - 172
src/Core/Logger.php

@@ -1,172 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-final class Logger
-{
-    private static ?string $requestId = null;
-
-    private static string $channel = 'stderr'; // stderr|file
-    private static string $path = '';
-    private static string $level = 'info';
-    private static int $maxBytes = 5242880; // 5MB
-    private static array $redactKeys = [];
-
-    private const LEVELS = [
-        'debug' => 10,
-        'info'  => 20,
-        'warn'  => 30,
-        'error' => 40,
-    ];
-
-    public static function init(): void
-    {
-        self::$requestId = substr(bin2hex(random_bytes(8)), 0, 16);
-
-        // Env jest już załadowany u Ciebie przed Logger::init()
-        self::$channel = 'file';
-        self::$path = 'logs/eksrelay.log';
-        $level = strtolower(trim((string)(Env::get('LOG_LEVEL') ?? 'info')));
-        self::$level = $level !== '' ? $level : 'info';
-        self::$maxBytes = (int)(Env::get('LOG_MAX_BYTES') ?? self::$maxBytes);
-
-        $rk = (string)(Env::get('LOG_REDACT_KEYS') ?? '');
-        self::$redactKeys = array_values(array_filter(array_map('trim', explode(',', strtolower($rk)))));
-
-        // jeśli file wybrane, ale brak ścieżki => fallback
-        if (self::$channel === 'file' && self::$path === '') {
-            self::$channel = 'stderr';
-        }
-    }
-
-    public static function getRequestId(): string
-    {
-        return self::$requestId ?? 'unknown';
-    }
-
-    public static function debug(string $message, array $context = []): void
-    {
-        self::log('debug', $message, $context);
-    }
-
-    public static function info(string $message, array $context = []): void
-    {
-        self::log('info', $message, $context);
-    }
-
-    public static function warn(string $message, array $context = []): void
-    {
-        self::log('warn', $message, $context);
-    }
-
-    public static function error(string $message, array $context = []): void
-    {
-        self::log('error', $message, $context);
-    }
-
-    private static function shouldLog(string $level): bool
-    {
-        $min = self::LEVELS[self::$level] ?? 20;
-        $cur = self::LEVELS[$level] ?? 20;
-        return $cur >= $min;
-    }
-
-    private static function log(string $level, string $message, array $context): void
-    {
-        if (!self::shouldLog($level)) {
-            return;
-        }
-
-        $entry = [
-            'timestamp'  => gmdate('Y-m-d\TH:i:s\Z'),
-            'level'      => $level,
-            'request_id' => self::$requestId ?? 'unknown',
-            'message'    => $message,
-        ];
-
-        if (isset($context['conversation_id'])) {
-            $entry['conversation_id'] = $context['conversation_id'];
-            unset($context['conversation_id']);
-        }
-        if (isset($context['message_id'])) {
-            $entry['message_id'] = $context['message_id'];
-            unset($context['message_id']);
-        }
-
-        if ($context !== []) {
-            $entry['context'] = self::redact($context);
-        }
-
-        $line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
-
-        self::writeLine($line . "\n");
-    }
-
-    private static function redact(array $data): array
-    {
-        if (self::$redactKeys === []) return $data;
-
-        $out = [];
-        foreach ($data as $k => $v) {
-            $key = strtolower((string)$k);
-
-            if (in_array($key, self::$redactKeys, true)) {
-                $out[$k] = '[REDACTED]';
-                continue;
-            }
-
-            if (is_array($v)) {
-                $out[$k] = self::redact($v);
-            } else {
-                $out[$k] = $v;
-            }
-        }
-        return $out;
-    }
-
-    private static function writeLine(string $line): void
-    {
-        if (self::$channel === 'file') {
-            self::writeToFile($line);
-            return;
-        }
-
-        // stderr/stdout fallback
-        if (defined('STDOUT')) {
-            fwrite(\STDOUT, $line);
-        } else {
-            error_log(rtrim($line, "\n"));
-        }
-    }
-
-    private static function writeToFile(string $line): void
-    {
-        $path = self::resolvePath(self::$path);
-
-        $dir = dirname($path);
-        if (!is_dir($dir)) {
-            if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
-                error_log("EKSRelay Logger: cannot mkdir {$dir}");
-                return;
-            }
-        }
-
-        $ok = file_put_contents($path, $line, FILE_APPEND | LOCK_EX);
-        if ($ok === false) {
-            error_log("EKSRelay Logger: cannot write {$path}");
-        }
-    }
-
-
-    private static function resolvePath(string $path): string
-    {
-        // Pozwól na ścieżkę względną względem katalogu /public
-        if ($path === '') return $path;
-        if ($path[0] === '/' || preg_match('~^[A-Za-z]:\\\\~', $path) === 1) {
-            return $path;
-        }
-        return rtrim(__DIR__ . '/../../', '/') . '/' . ltrim($path, '/');
-    }
-}

+ 0 - 78
src/Core/Router.php

@@ -1,78 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-final class Router
-{
-    /** @var array<string, array<string, callable>> */
-    private array $routes = [];
-
-    public function post(string $path, callable $handler): void
-    {
-        $this->routes['POST'][$path] = $handler;
-    }
-
-    public function dispatch(string $method, string $uri): void
-    {
-        // Strip query string
-        $path = parse_url($uri, PHP_URL_PATH);
-        $path = '/' . trim((string)$path, '/');
-
-        if (!isset($this->routes[$method][$path])) {
-            self::sendJson(404, [
-                'ok'      => false,
-                'code'    => 'NOT_FOUND',
-                'message' => "No route for {$method} {$path}",
-            ]);
-            return;
-        }
-
-        $handler = $this->routes[$method][$path];
-
-        try {
-            $handler();
-        } catch (HttpException $e) {
-            Logger::warn("HttpException: {$e->getMessage()}", [
-                'http_code'  => $e->httpCode,
-                'error_code' => $e->errorCode,
-            ]);
-            self::sendJson($e->httpCode, $e->toArray());
-        } catch (\Throwable $e) {
-            Logger::error("Unhandled exception: {$e->getMessage()}", [
-                'exception' => get_class($e),
-                'file'      => $e->getFile(),
-                'line'      => $e->getLine(),
-            ]);
-            self::sendJson(500, [
-                'ok'      => false,
-                'code'    => 'INTERNAL_ERROR',
-                'message' => 'An unexpected error occurred.',
-            ]);
-        }
-    }
-
-    public static function sendJson(int $statusCode, array $data): void
-    {
-        http_response_code($statusCode);
-        header('Content-Type: application/json; charset=utf-8');
-        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
-    }
-
-    /**
-     * Read and decode JSON request body.
-     */
-    public static function jsonBody(): array
-    {
-        $raw = file_get_contents('php://input');
-        if ($raw === '' || $raw === false) {
-            throw new HttpException(400, 'INVALID_BODY', 'Request body is empty.');
-        }
-        $data = json_decode($raw, true);
-        if (!is_array($data)) {
-            throw new HttpException(400, 'INVALID_JSON', 'Request body is not valid JSON.');
-        }
-        return $data;
-    }
-}

+ 0 - 121
src/Core/StoreLocales.php

@@ -1,121 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Core;
-
-/**
- * Maps WPML language codes to ISO 4217 currency codes.
- *
- * Used to resolve the correct shop currency based on the detected language
- * of the customer's message. Handles fallback to EUR for currencies that are
- * not configured in the shop's multicurrency plugin.
- *
- * Supported currencies are read from the STORE_CURRENCIES env variable
- * (comma-separated ISO 4217 codes). Default matches the shop configuration.
- */
-final class StoreLocales
-{
-    /**
-     * WPML language code → natural ISO 4217 currency code.
-     * Based on the official currency of the country/region for each language.
-     */
-    private const CURRENCY_MAP = [
-        'aed'   => 'AED',  // Arabic (UAE) → UAE Dirham
-        'be'    => 'BYN',  // Belarusian → Belarusian ruble (not in shop → fallback)
-        'bg'    => 'BGN',  // Bulgarian → Bulgarian lev
-        'hr'    => 'EUR',  // Croatian → Euro (adopted 2023)
-        'cs'    => 'CZK',  // Czech → Czech koruna
-        'da'    => 'DKK',  // Danish → Danish krone
-        'nl'    => 'EUR',  // Dutch → Euro
-        'en'    => 'EUR',  // English → Euro (default for EU context)
-        'et'    => 'EUR',  // Estonian → Euro
-        'fi'    => 'EUR',  // Finnish → Euro
-        'fr'    => 'EUR',  // French → Euro
-        'de'    => 'EUR',  // German → Euro
-        'el'    => 'EUR',  // Greek → Euro
-        'hu'    => 'HUF',  // Hungarian → Hungarian forint
-        'it'    => 'EUR',  // Italian → Euro
-        'lv'    => 'EUR',  // Latvian → Euro
-        'lt'    => 'EUR',  // Lithuanian → Euro
-        'no'    => 'NOK',  // Norwegian → Norwegian krone
-        'pl'    => 'PLN',  // Polish → Polish złoty
-        'pt-pt' => 'EUR',  // Portuguese (Portugal) → Euro
-        'ro'    => 'RON',  // Romanian → Romanian leu
-        'sk'    => 'EUR',  // Slovak → Euro
-        'sl'    => 'EUR',  // Slovenian → Euro
-        'es'    => 'EUR',  // Spanish → Euro
-        'sv'    => 'SEK',  // Swedish → Swedish krona
-        'tr'    => 'TRY',  // Turkish → Turkish lira (not in shop → fallback)
-        'uk'    => 'UAH',  // Ukrainian → Ukrainian hryvnia (not in shop → fallback)
-    ];
-
-    /** Fallback currency used when the natural currency is not supported. */
-    public const FALLBACK_CURRENCY = 'EUR';
-
-    /**
-     * Default list of currencies configured in the shop's multicurrency plugin.
-     * Used when STORE_CURRENCIES env variable is not set.
-     * Keep in sync with WooCommerce multicurrency configuration.
-     */
-    private const DEFAULT_SUPPORTED = 'PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP';
-
-    /**
-     * Resolve a WPML language code to a shop-supported ISO 4217 currency.
-     *
-     * Returns an array with:
-     *   currency         — ISO 4217 code to pass to the WooCommerce API
-     *   fallback         — true if we had to fall back from the natural currency
-     *   natural_currency — (only when fallback=true) the natural currency for the language
-     *   reason           — (only when fallback=true) 'unknown_language' | 'unsupported_currency'
-     *
-     * @param string|null $language WPML language code (e.g. 'de', 'pl', 'pt-pt')
-     */
-    public static function resolve(?string $language): array
-    {
-        if ($language === null || $language === '') {
-            return ['currency' => self::FALLBACK_CURRENCY, 'fallback' => false];
-        }
-
-        $lang    = strtolower(trim($language));
-        $natural = self::CURRENCY_MAP[$lang] ?? null;
-
-        if ($natural === null) {
-            return [
-                'currency' => self::FALLBACK_CURRENCY,
-                'fallback' => true,
-                'reason'   => 'unknown_language',
-            ];
-        }
-
-        if (!in_array($natural, self::supportedCurrencies(), true)) {
-            return [
-                'currency'         => self::FALLBACK_CURRENCY,
-                'fallback'         => true,
-                'reason'           => 'unsupported_currency',
-                'natural_currency' => $natural,
-            ];
-        }
-
-        return ['currency' => $natural, 'fallback' => false];
-    }
-
-    /**
-     * Check whether a currency code is supported by the shop's multicurrency configuration.
-     *
-     * @param string $currency ISO 4217 code (comparison is case-insensitive)
-     */
-    public static function isSupported(string $currency): bool
-    {
-        return in_array(strtoupper($currency), self::supportedCurrencies(), true);
-    }
-
-    /**
-     * @return string[]
-     */
-    private static function supportedCurrencies(): array
-    {
-        $raw = Env::get('STORE_CURRENCIES', self::DEFAULT_SUPPORTED);
-        return array_values(array_filter(array_map('trim', explode(',', strtoupper($raw)))));
-    }
-}

+ 0 - 190
src/Handlers/ChatwootWebhookHandler.php

@@ -1,190 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Handlers;
-
-use EKSRelay\Clients\ChatwootClient;
-use EKSRelay\Clients\FlowiseClient;
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-use EKSRelay\Core\Router;
-
-final class ChatwootWebhookHandler
-{
-    public static function handle(): void
-    {
-        $payload = Router::jsonBody();
-
-        $event       = $payload['event'] ?? '';
-        $messageType = $payload['message_type'] ?? '';
-
-        // Only process incoming messages
-        if ($event !== 'message_created' || $messageType !== 'incoming') {
-            Logger::info('Ignored webhook event', ['event' => $event, 'message_type' => $messageType]);
-            Router::sendJson(200, ['ok' => true, 'skipped' => true, 'reason' => 'not_incoming_message']);
-            return;
-        }
-
-        $conversation   = $payload['conversation'] ?? [];
-        $conversationId = (int)($conversation['id'] ?? 0);
-        $inboxId        = (int)($conversation['inbox_id'] ?? 0);
-        $content        = trim((string)($payload['content'] ?? ''));
-        $sender         = $payload['sender'] ?? [];
-        $messageId      = $payload['id'] ?? null;
-
-        if ($conversationId === 0) {
-            throw new HttpException(400, 'MISSING_CONVERSATION_ID', 'Payload is missing conversation.id');
-        }
-
-        $logCtx = ['conversation_id' => $conversationId, 'message_id' => $messageId];
-        Logger::info('Processing incoming message', $logCtx);
-
-        // -----------------------------------------------------------------
-        // Extract contact email from multiple sources (most reliable first)
-        // -----------------------------------------------------------------
-        $meta       = $conversation['meta'] ?? [];
-        $metaSender = $meta['sender'] ?? [];
-        $contactInbox = $conversation['contact_inbox'] ?? [];
-
-        $rawEmail   = (string)($metaSender['email']
-                        ?? $sender['email']
-                        ?? $contactInbox['source_id']
-                        ?? '');
-        // source_id may not be an email (e.g. on non-email channels)
-        $senderEmail = filter_var($rawEmail, FILTER_VALIDATE_EMAIL) ? $rawEmail : '';
-        $senderName  = (string)($metaSender['name'] ?? $sender['name'] ?? '');
-
-        Logger::debug('Resolved sender', ['email' => $senderEmail, 'name' => $senderName]);
-
-        $chatwoot = new ChatwootClient();
-
-        // -----------------------------------------------------------------
-        // Fetch full conversation details if labels/attributes incomplete
-        // -----------------------------------------------------------------
-        $labels = $conversation['labels'] ?? null;
-        $additionalAttrs = $conversation['additional_attributes'] ?? [];
-        $customAttrs = $conversation['custom_attributes'] ?? null;
-
-        $convData = null;
-        if ($labels === null || $customAttrs === null) {
-            Logger::info('Fetching full conversation from Chatwoot API', $logCtx);
-            $convData = $chatwoot->getConversation($conversationId);
-            $labels       = $convData['labels'] ?? [];
-            $customAttrs  = $convData['custom_attributes'] ?? [];
-            $additionalAttrs = array_merge($additionalAttrs, $convData['additional_attributes'] ?? []);
-        }
-
-        // -----------------------------------------------------------------
-        // STOP check: ticket label or handoff=true
-        // -----------------------------------------------------------------
-        if ($chatwoot->isTicketMode($conversationId, $convData ?? array_merge($conversation, [
-            'labels'            => $labels,
-            'custom_attributes' => $customAttrs,
-        ]))) {
-            Logger::info('Conversation is in ticket/manual mode — skipping', $logCtx);
-            Router::sendJson(200, ['ok' => true, 'skipped' => true, 'reason' => 'ticket_mode']);
-            return;
-        }
-
-        // -----------------------------------------------------------------
-        // Build Flowise request
-        // -----------------------------------------------------------------
-
-        // Prepend contact context so the LLM can access it directly
-        // (avoids the need for Variable nodes in Flowise)
-        $contextLines = [];
-        if ($senderEmail !== '') {
-            $contextLines[] = "Email klienta: {$senderEmail}";
-        }
-        if ($senderName !== '') {
-            $contextLines[] = "Imię klienta: {$senderName}";
-        }
-        $question = $contextLines
-            ? implode("\n", $contextLines) . "\n\n---\n" . $content
-            : $content;
-
-        $flowisePayload = [
-            'question'       => $question,
-            'overrideConfig' => [
-                'sessionId'      => "chatwoot:{$conversationId}",
-                'conversationId' => $conversationId,
-                'inboxId'        => $inboxId,
-            ],
-            'metadata' => [
-                'source'      => 'chatwoot',
-                'event'       => $event,
-                'messageType' => $messageType,
-                'sender'      => [
-                    'id'    => $sender['id'] ?? null,
-                    'email' => $sender['email'] ?? null,
-                    'name'  => $sender['name'] ?? null,
-                ],
-                'labels'  => $labels,
-                'subject' => $additionalAttrs['mail_subject'] ?? ($additionalAttrs['subject'] ?? null),
-            ],
-        ];
-
-        if ($messageId !== null) {
-            $flowisePayload['overrideConfig']['messageId'] = $messageId;
-        }
-
-        // -----------------------------------------------------------------
-        // Call Flowise
-        // -----------------------------------------------------------------
-        Logger::debug('Flowise payload', ['payload' => $flowisePayload]);
-
-        $flowise  = new FlowiseClient();
-        $response = $flowise->predict($flowisePayload);
-
-        Logger::info('Flowise response', array_merge($logCtx, ['type' => $response['type']]));
-
-        // -----------------------------------------------------------------
-        // Handle handoff
-        // -----------------------------------------------------------------
-        if ($response['type'] === 'handoff') {
-            $ticketResult = NewTicketHandler::createTicket($conversationId);
-            $ticketNumber = $ticketResult['ticketNumber'];
-
-            $replyText = $response['text']
-                ?? "Twoje zgłoszenie zostało utworzone. Numer ticketu: {$ticketNumber}. Nasz zespół wkrótce się z Tobą skontaktuje.";
-
-            // Include ticket number if Flowise text doesn't already contain it
-            if ($response['text'] !== null && !str_contains($response['text'], $ticketNumber)) {
-                $replyText .= "\n\nNumer ticketu: {$ticketNumber}";
-            }
-
-            // Re-check ticket mode before sending (race condition guard)
-            if (!$chatwoot->isTicketMode($conversationId)) {
-                Logger::warn('Conversation became ticket during handoff — message might duplicate', $logCtx);
-            }
-
-            $chatwoot->sendOutgoingMessage($conversationId, $replyText);
-            Logger::info('Handoff completed', array_merge($logCtx, ['ticket' => $ticketNumber]));
-
-            Router::sendJson(200, ['ok' => true, 'action' => 'handoff', 'ticketNumber' => $ticketNumber]);
-            return;
-        }
-
-        // -----------------------------------------------------------------
-        // Handle normal reply
-        // -----------------------------------------------------------------
-        if ($response['type'] === 'reply' && $response['text'] !== null && $response['text'] !== '') {
-            // Note: we intentionally do NOT re-check isTicketMode here.
-            // The conversation was confirmed NOT a ticket before calling Flowise.
-            // If a ticket was created by the agent's tool during this call,
-            // the agent's confirmation message should still reach the user.
-            $chatwoot->sendOutgoingMessage($conversationId, $response['text']);
-            Logger::info('Reply sent', $logCtx);
-            Router::sendJson(200, ['ok' => true, 'action' => 'reply']);
-            return;
-        }
-
-        // -----------------------------------------------------------------
-        // Unknown / empty response
-        // -----------------------------------------------------------------
-        Logger::warn('Flowise returned no usable response', array_merge($logCtx, ['response' => $response]));
-        Router::sendJson(200, ['ok' => true, 'action' => 'no_reply', 'reason' => 'empty_flowise_response']);
-    }
-}

+ 0 - 104
src/Handlers/NewTicketHandler.php

@@ -1,104 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Handlers;
-
-use EKSRelay\Clients\ChatwootClient;
-use EKSRelay\Core\Auth;
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-use EKSRelay\Core\Router;
-
-final class NewTicketHandler
-{
-    /**
-     * HTTP handler for POST /tools/new_ticket
-     */
-    public static function handle(): void
-    {
-        Auth::requireBearer();
-
-        $body           = Router::jsonBody();
-        $conversationId = (int)($body['conversationId'] ?? 0);
-
-        if ($conversationId === 0) {
-            throw new HttpException(400, 'MISSING_PARAMS', 'conversationId is required.');
-        }
-
-        $result = self::createTicket($conversationId);
-        Router::sendJson(200, $result);
-    }
-
-    /**
-     * Core ticket creation logic — also called internally from webhook handler.
-     *
-     * @return array{ok: bool, ticketNumber: string, status: string}
-     */
-    public static function createTicket(int $conversationId): array
-    {
-        $chatwoot    = new ChatwootClient();
-        $ticketLabel = Env::get('CHATWOOT_TICKET_LABEL', 'ticket');
-
-        $logCtx = ['conversation_id' => $conversationId];
-
-        // -----------------------------------------------------------------
-        // Idempotency: check if already a ticket
-        // -----------------------------------------------------------------
-        $conv        = $chatwoot->getConversation($conversationId);
-        $labels      = $conv['labels'] ?? [];
-        $customAttrs = $conv['custom_attributes'] ?? [];
-
-        $alreadyTicket = in_array($ticketLabel, $labels, true)
-            || (isset($customAttrs['handoff']) && ($customAttrs['handoff'] === true || $customAttrs['handoff'] === 'true'));
-
-        if ($alreadyTicket) {
-            $existingNumber = $customAttrs['ticket_number'] ?? null;
-
-            if ($existingNumber === null || $existingNumber === '') {
-                // Label exists but no ticket_number — generate and set it
-                $existingNumber = self::generateTicketNumber($conversationId);
-                $chatwoot->setCustomAttributes($conversationId, ['ticket_number' => $existingNumber]);
-            }
-
-            Logger::info('Ticket already exists', array_merge($logCtx, ['ticket' => $existingNumber]));
-
-            return [
-                'ok'           => true,
-                'ticketNumber' => (string)$existingNumber,
-                'status'       => 'existing',
-            ];
-        }
-
-        // -----------------------------------------------------------------
-        // Create new ticket
-        // -----------------------------------------------------------------
-        $ticketNumber = self::generateTicketNumber($conversationId);
-
-        // 1. Set custom attributes
-        $chatwoot->setCustomAttributes($conversationId, [
-            'handoff'       => true,
-            'ticket_number' => $ticketNumber,
-        ]);
-
-        // 3. Add label
-        $chatwoot->addLabel($conversationId, $ticketLabel);
-
-        Logger::info('Ticket created', array_merge($logCtx, ['ticket' => $ticketNumber]));
-
-        return [
-            'ok'           => true,
-            'ticketNumber' => $ticketNumber,
-            'status'       => 'created',
-        ];
-    }
-
-    /**
-     * Ticket number = conversation ID (always unique in Chatwoot scope).
-     */
-    private static function generateTicketNumber(int $conversationId): string
-    {
-        return (string)$conversationId;
-    }
-}

+ 0 - 455
src/Handlers/WooToolsHandler.php

@@ -1,455 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace EKSRelay\Handlers;
-
-use EKSRelay\Clients\WooCommerceClient;
-use EKSRelay\Core\Auth;
-use EKSRelay\Core\Env;
-use EKSRelay\Core\HttpClient;
-use EKSRelay\Core\HttpException;
-use EKSRelay\Core\Logger;
-use EKSRelay\Core\Router;
-use EKSRelay\Core\StoreLocales;
-
-/**
- * Handlers for all WooCommerce-backed tool endpoints.
- */
-final class WooToolsHandler
-{
-    // =================================================================
-    // POST /tools/get_order_data
-    // =================================================================
-    public static function getOrderData(): void
-    {
-        Auth::requireBearer();
-        $body = Router::jsonBody();
-
-        $orderNumber = $body['orderNumber'] ?? ($body['order_number'] ?? null);
-        $email       = $body['email'] ?? null;
-
-        if ($orderNumber !== null) {
-            $orderNumber = (string)$orderNumber;
-        }
-
-        if ($email === null || $email === '') {
-            throw new HttpException(400, 'MISSING_PARAMS', 'email is required to verify order ownership.');
-        }
-
-        $language         = $body['language'] ?? null;
-        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
-        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
-            ? ['currency' => $currencyOverride, 'fallback' => false]
-            : StoreLocales::resolve($language);
-
-        $woo = new WooCommerceClient();
-
-        if ($orderNumber !== null && $orderNumber !== '') {
-            $order = $woo->getOrder(orderNumber: $orderNumber, currency: $locale['currency']);
-            // Security: verify that the caller's email matches the billing email on the order.
-            $billingEmail = strtolower(trim($order['billing']['email'] ?? ''));
-            if ($billingEmail === '' || $billingEmail !== strtolower(trim($email))) {
-                throw new HttpException(403, 'UNAUTHORIZED', 'Provided email does not match the order.');
-            }
-        } else {
-            $order = $woo->getOrder(email: $email, currency: $locale['currency']);
-        }
-
-        $response = [
-            'ok'       => true,
-            'data'     => self::formatOrder($order),
-            'currency' => $locale['currency'],
-        ];
-        if ($locale['fallback']) {
-            $response['currency_fallback'] = true;
-            $response['currency_note']     = self::buildCurrencyNote($locale, $language);
-        }
-
-        // Return a curated subset useful for the LLM agent
-        Router::sendJson(200, $response);
-    }
-
-    // =================================================================
-    // POST /tools/get_product_data
-    // =================================================================
-    public static function getProductData(): void
-    {
-        Auth::requireBearer();
-        $body = Router::jsonBody();
-
-        $productId = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
-        $sku       = $body['sku'] ?? null;
-        $search    = $body['search'] ?? null;
-        $language         = $body['language'] ?? null;
-        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
-        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
-            ? ['currency' => $currencyOverride, 'fallback' => false]
-            : StoreLocales::resolve($language);
-
-        $woo = new WooCommerceClient();
-
-        $currencyMeta = ['currency' => $locale['currency']];
-        if ($locale['fallback']) {
-            $currencyMeta['currency_fallback'] = true;
-            $currencyMeta['currency_note']     = self::buildCurrencyNote($locale, $language);
-        }
-
-        $lang = $language ?? '';
-
-        if ($productId !== null && $productId > 0) {
-            $product = $woo->getProduct(productId: $productId, currency: $locale['currency'], lang: $lang);
-            Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
-            return;
-        }
-
-        if ($sku !== null && $sku !== '') {
-            $product = $woo->getProduct(sku: $sku, currency: $locale['currency'], lang: $lang);
-            Router::sendJson(200, array_merge(['ok' => true, 'data' => self::formatProduct($product)], $currencyMeta));
-            return;
-        }
-
-        if ($search !== null && $search !== '') {
-            $products  = $woo->searchProducts($search, currency: $locale['currency'], lang: $lang);
-            $formatted = array_map(fn($p) => self::formatProduct($p), $products);
-            Router::sendJson(200, array_merge(['ok' => true, 'data' => $formatted], $currencyMeta));
-            return;
-        }
-
-        throw new HttpException(400, 'MISSING_PARAMS', 'Provide productId, sku, or search.');
-    }
-
-    // =================================================================
-    // POST /tools/get_shipping_data
-    // =================================================================
-    public static function getShippingData(): void
-    {
-        Auth::requireBearer();
-        $body = Router::jsonBody();
-
-        $language         = $body['language'] ?? null;
-        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
-        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
-            ? ['currency' => $currencyOverride, 'fallback' => false]
-            : StoreLocales::resolve($language);
-
-        $woo = new WooCommerceClient();
-
-        // If zoneId provided — return methods for that specific zone
-        $zoneId = $body['zoneId'] ?? ($body['zone_id'] ?? null);
-
-        try {
-            if ($zoneId !== null) {
-                // Use custom WP endpoint to get per-currency shipping costs
-                // (WC REST API only returns PLN costs; WCML costs are in wp_options)
-                $wpBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
-                $url    = $wpBase . '/shipping-costs?' . http_build_query([
-                    'zone_id'  => (int)$zoneId,
-                    'currency' => $locale['currency'],
-                ]);
-                $res = HttpClient::request('GET', $url);
-
-                $response = ['ok' => true, 'data' => $res['json'] ?? [], 'currency' => $locale['currency']];
-                if ($locale['fallback']) {
-                    $response['currency_fallback'] = true;
-                    $response['currency_note']     = self::buildCurrencyNote($locale, $language);
-                }
-                Router::sendJson(200, $response);
-                return;
-            }
-
-            // Default: return list of zones with ISO country codes
-            // Zone 0 (Rest of the World) has no locations — it's the fallback zone.
-            $zones     = $woo->getShippingZones();
-            $formatted = [];
-            foreach ($zones as $z) {
-                $zid       = (int)($z['id'] ?? 0);
-                $locations = $woo->getShippingZoneLocations($zid);
-                // Extract country-level ISO codes only (skip state/continent entries)
-                $countries = array_values(array_filter(array_map(
-                    fn($l) => ($l['type'] ?? '') === 'country' ? strtoupper((string)($l['code'] ?? '')) : null,
-                    $locations
-                )));
-                $formatted[] = [
-                    'id'        => $zid,
-                    'name'      => $z['name'] ?? '',
-                    'countries' => $countries, // ISO 3166-1 alpha-2 codes, e.g. ["PL"] or ["GB","IE"]
-                ];
-            }
-
-            Router::sendJson(200, [
-                'ok'   => true,
-                'data' => $formatted,
-                'hint' => 'Pass {"zoneId": <id>} to get shipping methods for a specific zone.',
-            ]);
-        } catch (HttpException $e) {
-            if ($e->httpCode === 502) {
-                Router::sendJson(200, [
-                    'ok'      => false,
-                    'code'    => 'NOT_IMPLEMENTED',
-                    'message' => 'Shipping data is not available via WooCommerce REST API. '
-                        . 'Ensure shipping zones are configured and the consumer key has read access to shipping endpoints.',
-                ]);
-                return;
-            }
-            throw $e;
-        }
-    }
-
-    // =================================================================
-    // POST /tools/get_payment_methods
-    // =================================================================
-    public static function getPaymentMethods(): void
-    {
-        Auth::requireBearer();
-
-        $woo = new WooCommerceClient();
-
-        try {
-            $gateways = $woo->getPaymentGateways();
-            // Filter to enabled gateways only
-            $enabled = array_values(array_filter($gateways, fn($g) => ($g['enabled'] ?? false) === true));
-
-            $formatted = array_map(fn($g) => [
-                'id'          => $g['id'] ?? '',
-                'title'       => $g['title'] ?? '',
-                'description' => $g['description'] ?? '',
-                'enabled'     => $g['enabled'] ?? false,
-            ], $enabled);
-
-            Router::sendJson(200, ['ok' => true, 'data' => $formatted]);
-        } catch (HttpException $e) {
-            if ($e->httpCode === 502) {
-                Router::sendJson(200, [
-                    'ok'      => false,
-                    'code'    => 'NOT_IMPLEMENTED',
-                    'message' => 'Payment gateways endpoint not accessible. '
-                        . 'Ensure the WooCommerce consumer key has admin-level (read/write) permissions '
-                        . 'to access GET /payment_gateways.',
-                ]);
-                return;
-            }
-            throw $e;
-        }
-    }
-
-    // =================================================================
-    // POST /tools/get_product_compatibility
-    // =================================================================
-    public static function getProductCompatibility(): void
-    {
-        Auth::requireBearer();
-        $body = Router::jsonBody();
-
-        $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
-
-        $productName      = $body['product_name'] ?? ($body['productName'] ?? null);
-        $productId        = isset($body['productId']) ? (int)$body['productId'] : (isset($body['product_id']) ? (int)$body['product_id'] : null);
-        $carBrand         = $body['car_brand']    ?? ($body['carBrand']   ?? null);
-        $carModel         = $body['car_model']    ?? ($body['carModel']   ?? null);
-        $carYear          = $body['car_year']     ?? ($body['carYear']    ?? null);
-        $carEngine        = $body['car_engine']        ?? ($body['carEngine']       ?? null);
-        $carEngineIndex   = isset($body['car_engine_index']) ? (int)$body['car_engine_index'] : -1;
-        $language         = $body['language']     ?? null;
-        $currencyOverride = isset($body['currency']) ? strtoupper(trim((string)$body['currency'])) : null;
-        $locale           = ($currencyOverride !== null && $currencyOverride !== '' && StoreLocales::isSupported($currencyOverride))
-            ? ['currency' => $currencyOverride, 'fallback' => false]
-            : StoreLocales::resolve($language);
-
-        // When a non-Polish product name is given, try to pre-resolve it to a product ID
-        // via the WooCommerce REST API (which has a PL language fallback). This prevents
-        // the WP endpoint from failing to find products with non-Polish search terms.
-        if ($productName !== null && $productName !== '' && ($productId === null || $productId === 0)) {
-            $woo   = new WooCommerceClient();
-            $found = $woo->searchProducts($productName, 5, '', $language ?? '');
-            if (empty($found) && $language && $language !== 'pl') {
-                // Fallback: search in PL (default) database without language filter
-                $found = $woo->searchProducts($productName, 5, '');
-            }
-            if (count($found) === 1) {
-                // Unambiguous match — use product ID so WP endpoint skips name-based search
-                $productId   = (int)$found[0]['id'];
-                $productName = null;
-            }
-            // Multiple matches or no match: pass product_name to WP endpoint as usual
-        }
-
-        $payload = [];
-        if ($productId !== null && $productId > 0)       $payload['product_id']   = $productId;
-        if ($productName !== null && $productName !== '') $payload['product_name'] = $productName;
-        if ($carBrand    !== null && $carBrand    !== '') $payload['car_brand']    = $carBrand;
-        if ($carModel    !== null && $carModel    !== '') $payload['car_model']    = $carModel;
-        if ($carYear     !== null && $carYear     !== '') $payload['car_year']     = $carYear;
-        if ($carEngine      !== null && $carEngine      !== '') $payload['car_engine']       = $carEngine;
-        if ($carEngineIndex >= 0)                              $payload['car_engine_index'] = $carEngineIndex;
-        if ($language       !== null && $language       !== '') $payload['lang']             = $language;
-        $payload['currency'] = $locale['currency'];
-
-        $res  = HttpClient::request('POST', $restBase . '/product-compatibility', [], $payload);
-        $data = $res['json'] ?? $res['body'];
-
-        // Post-process product titles: the WP endpoint does not switch WPML for the
-        // selected_product branch, so titles come back in Polish. Re-fetch from WC REST
-        // API with the correct language to get the translated title.
-        if (is_array($data) && $language !== null && $language !== '' && $language !== 'pl') {
-            $woo = new WooCommerceClient();
-
-            // Case A: specific product compatibility check — replace title in selected_product
-            if (isset($data['selected_product']['id'])) {
-                try {
-                    $translated = $woo->getProduct(
-                        productId: (int)$data['selected_product']['id'],
-                        lang: $language,
-                        currency: $locale['currency']
-                    );
-                    $data['selected_product']['title'] = $translated['name'] ?? $data['selected_product']['title'];
-                } catch (\Throwable) { /* keep original title on failure */ }
-            }
-
-            // Case B: disambiguation options list for product_name — batch-translate titles
-            if (
-                ($data['error'] ?? '') === 'options'
-                && ($data['field'] ?? '') === 'product_name'
-                && !empty($data['options'])
-                && is_array($data['options'])
-            ) {
-                $ids = array_values(array_filter(array_column($data['options'], 'id')));
-                if (!empty($ids)) {
-                    try {
-                        $translated  = $woo->getProductsByIds($ids, $language, $locale['currency']);
-                        $byId        = [];
-                        foreach ($translated as $tp) {
-                            $byId[(int)($tp['id'] ?? 0)] = $tp['name'] ?? null;
-                        }
-                        foreach ($data['options'] as &$opt) {
-                            $pid = (int)($opt['id'] ?? 0);
-                            if ($pid && isset($byId[$pid])) {
-                                $opt['title'] = $byId[$pid];
-                            }
-                        }
-                        unset($opt);
-                    } catch (\Throwable) { /* keep original titles on failure */ }
-                }
-            }
-        }
-
-        $response = [
-            'ok'       => true,
-            'data'     => $data,
-            'currency' => $locale['currency'],
-        ];
-        if ($locale['fallback']) {
-            $response['currency_fallback'] = true;
-            $response['currency_note']     = self::buildCurrencyNote($locale, $language);
-        }
-
-        Router::sendJson(200, $response);
-    }
-
-    // =================================================================
-    // POST /tools/get_car_data
-    // =================================================================
-    public static function getCarData(): void
-    {
-        Auth::requireBearer();
-        $body = Router::jsonBody();
-
-        $restBase = rtrim(Env::get('WOOCOMMERCE_BASE_URL'), '/') . '/wp-json/eksrelay/v1';
-
-        $carBrand  = $body['car_brand']  ?? ($body['make']   ?? null);
-        $carModel  = $body['car_model']  ?? ($body['model']  ?? null);
-        $carYear   = $body['car_year']   ?? ($body['year']   ?? null);
-        $carEngine = $body['car_engine'] ?? ($body['engine'] ?? null);
-
-        $payload = [];
-        if ($carBrand  !== null && $carBrand  !== '') $payload['car_brand']  = $carBrand;
-        if ($carModel  !== null && $carModel  !== '') $payload['car_model']  = $carModel;
-        if ($carYear   !== null && $carYear   !== '') $payload['car_year']   = $carYear;
-        if ($carEngine !== null && $carEngine !== '') $payload['car_engine'] = $carEngine;
-
-        $res = HttpClient::request(
-            'POST',
-            $restBase . '/car-data',
-            [],
-            $payload
-        );
-
-        Router::sendJson(200, ['ok' => true, 'data' => $res['json'] ?? $res['body']]);
-    }
-
-    // =================================================================
-    // Formatters (curate data for LLM consumption)
-    // =================================================================
-
-    private static function formatOrder(array $order): array
-    {
-        return [
-            'id'               => $order['id'] ?? null,
-            'number'           => $order['number'] ?? null,
-            'status'           => $order['status'] ?? null,
-            'date_created'     => $order['date_created'] ?? null,
-            'total'            => $order['total'] ?? null,
-            'currency'         => $order['currency'] ?? null,
-            'billing'          => [
-                'first_name' => $order['billing']['first_name'] ?? '',
-                'last_name'  => $order['billing']['last_name'] ?? '',
-                'email'      => $order['billing']['email'] ?? '',
-                'phone'      => $order['billing']['phone'] ?? '',
-                'city'       => $order['billing']['city'] ?? '',
-                'country'    => $order['billing']['country'] ?? '',
-            ],
-            'shipping'         => [
-                'first_name' => $order['shipping']['first_name'] ?? '',
-                'last_name'  => $order['shipping']['last_name'] ?? '',
-                'city'       => $order['shipping']['city'] ?? '',
-                'country'    => $order['shipping']['country'] ?? '',
-                'address_1'  => $order['shipping']['address_1'] ?? '',
-            ],
-            'payment_method'       => $order['payment_method_title'] ?? null,
-            'shipping_total'       => $order['shipping_total'] ?? null,
-            'line_items'           => array_map(fn($item) => [
-                'name'     => $item['name'] ?? '',
-                'sku'      => $item['sku'] ?? '',
-                'quantity' => $item['quantity'] ?? 0,
-                'total'    => $item['total'] ?? '0',
-            ], $order['line_items'] ?? []),
-            'shipping_lines'       => array_map(fn($sl) => [
-                'method_title' => $sl['method_title'] ?? '',
-                'total'        => $sl['total'] ?? '0',
-            ], $order['shipping_lines'] ?? []),
-            'customer_note'        => $order['customer_note'] ?? '',
-        ];
-    }
-
-    private static function buildCurrencyNote(array $locale, ?string $language): string
-    {
-        if (($locale['reason'] ?? '') === 'unknown_language') {
-            return 'Unknown language code ' . ($language ?? '?') . '. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
-        }
-        $natural = $locale['natural_currency'] ?? ($language ?? '?');
-        return 'Currency for ' . $natural . ' is not supported in the store. Prices shown in ' . StoreLocales::FALLBACK_CURRENCY . '.';
-    }
-
-    private static function formatProduct(array $product): array
-    {
-        return [
-            'id'                => $product['id'] ?? null,
-            'name'              => $product['name'] ?? '',
-            'sku'               => $product['sku'] ?? '',
-            'slug'              => $product['slug'] ?? '',
-            'status'            => $product['status'] ?? '',
-            'price'             => $product['price'] ?? '',
-            'regular_price'     => $product['regular_price'] ?? '',
-            'sale_price'        => $product['sale_price'] ?? '',
-            'stock_status'      => $product['stock_status'] ?? '',
-            'stock_quantity'    => $product['stock_quantity'] ?? null,
-            'short_description' => strip_tags((string)($product['short_description'] ?? '')),
-            'categories'        => array_map(fn($c) => $c['name'] ?? '', $product['categories'] ?? []),
-            'attributes'        => array_map(fn($a) => [
-                'name'    => $a['name'] ?? '',
-                'options' => $a['options'] ?? [],
-            ], $product['attributes'] ?? []),
-            'permalink'         => $product['permalink'] ?? '',
-        ];
-    }
-}

+ 119 - 0
src/clients/chatwootClient.ts

@@ -0,0 +1,119 @@
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { upstream } from '../errors.js';
+import { request, safeLabel } from './httpClient.js';
+
+export interface ChatwootConversation {
+  id: number;
+  inbox_id?: number;
+  labels?: string[];
+  status?: string;
+  custom_attributes?: Record<string, unknown>;
+  additional_attributes?: Record<string, unknown>;
+  meta?: { sender?: { id?: number; name?: string; email?: string } };
+  contact_inbox?: { source_id?: string };
+}
+
+export class ChatwootClient {
+  private readonly baseUrl: string;
+  private readonly token: string;
+  private readonly accountId: number;
+
+  constructor() {
+    const cfg = config();
+    this.baseUrl = cfg.CHATWOOT_BASE_URL.replace(/\/+$/, '');
+    this.token = cfg.CHATWOOT_API_TOKEN;
+    this.accountId = cfg.CHATWOOT_ACCOUNT_ID;
+  }
+
+  private url(path: string): string {
+    return `${this.baseUrl}/api/v1/accounts/${this.accountId}${path}`;
+  }
+
+  private async api<T>(
+    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
+    path: string,
+    json?: unknown,
+  ): Promise<T> {
+    const url = this.url(path);
+    const res = await request<T>(url, {
+      method,
+      json,
+      headers: { api_access_token: this.token },
+      label: `chatwoot ${method} ${path}`,
+    });
+    if (res.status >= 400) {
+      logger.warn('Chatwoot API error', { path, status: res.status });
+      throw upstream('CHATWOOT_API_ERROR', `Chatwoot returned HTTP ${res.status} for ${path}.`);
+    }
+    return (res.json ?? ({} as T)) as T;
+  }
+
+  async getConversation(conversationId: number): Promise<ChatwootConversation> {
+    return this.api<ChatwootConversation>('GET', `/conversations/${conversationId}`);
+  }
+
+  /**
+   * Chatwoot's labels endpoint REPLACES the whole label set, so merge with what
+   * is already there. No-op when the label is already present.
+   */
+  async addLabel(conversationId: number, label: string): Promise<void> {
+    const conv = await this.getConversation(conversationId);
+    const existing = Array.isArray(conv.labels) ? conv.labels : [];
+    if (existing.includes(label)) return;
+    await this.api('POST', `/conversations/${conversationId}/labels`, {
+      labels: [...existing, label],
+    });
+    logger.info('Chatwoot label added', { conversationId, label });
+  }
+
+  async setCustomAttributes(
+    conversationId: number,
+    attrs: Record<string, unknown>,
+  ): Promise<void> {
+    await this.api('POST', `/conversations/${conversationId}/custom_attributes`, {
+      custom_attributes: attrs,
+    });
+    logger.info('Chatwoot custom attributes set', {
+      conversationId,
+      keys: Object.keys(attrs),
+    });
+  }
+
+  /** Best-effort unassign; a failure here must not abort ticket creation. */
+  async unassignConversation(conversationId: number): Promise<boolean> {
+    try {
+      await this.api('POST', `/conversations/${conversationId}/assignments`, {
+        assignee_id: null,
+      });
+      logger.info('Chatwoot conversation unassigned', { conversationId });
+      return true;
+    } catch {
+      logger.warn('Chatwoot unassign failed (non-fatal)', { conversationId });
+      return false;
+    }
+  }
+
+  async sendOutgoingMessage(conversationId: number, content: string): Promise<unknown> {
+    return this.api('POST', `/conversations/${conversationId}/messages`, {
+      content,
+      message_type: 'outgoing',
+      private: false,
+    });
+  }
+
+  /** Lightweight reachability probe for /ready — never exposes the token. */
+  async ping(): Promise<{ ok: boolean; status: number; target: string }> {
+    const url = this.url('/conversations?status=open&page=1');
+    try {
+      const res = await request(url, {
+        headers: { api_access_token: this.token },
+        label: 'chatwoot ping',
+        timeoutMs: 5_000,
+      });
+      return { ok: res.status < 400, status: res.status, target: safeLabel(url) };
+    } catch {
+      return { ok: false, status: 0, target: safeLabel(url) };
+    }
+  }
+}

+ 111 - 0
src/clients/flowiseClient.ts

@@ -0,0 +1,111 @@
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { upstream } from '../errors.js';
+import { request, safeLabel } from './httpClient.js';
+
+export interface FlowisePayload {
+  question: string;
+  overrideConfig: Record<string, unknown>;
+  metadata: Record<string, unknown>;
+}
+
+export interface FlowiseResult {
+  type: 'reply' | 'handoff' | 'unknown';
+  text: string | null;
+  actions: unknown[] | null;
+}
+
+export class FlowiseClient {
+  private readonly predictUrl: string;
+  private readonly apiKey: string;
+  private readonly timeoutMs: number;
+
+  constructor() {
+    const cfg = config();
+    this.predictUrl = cfg.FLOWISE_PREDICT_URL;
+    this.apiKey = cfg.FLOWISE_API_KEY;
+    this.timeoutMs = cfg.FLOWISE_TIMEOUT_MS;
+  }
+
+  async predict(payload: FlowisePayload): Promise<FlowiseResult> {
+    const headers: Record<string, string> = {};
+    // The PHP relay shipped a literal `Bearer ***` placeholder here; send the
+    // real key (and only when one is configured).
+    if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
+
+    logger.info('Calling Flowise', { target: safeLabel(this.predictUrl) });
+
+    const res = await request<Record<string, unknown>>(this.predictUrl, {
+      method: 'POST',
+      headers,
+      json: payload,
+      timeoutMs: this.timeoutMs,
+      label: 'flowise predict',
+    });
+
+    if (res.status >= 400) {
+      logger.error('Flowise error response', { status: res.status });
+      throw upstream('FLOWISE_ERROR', `Flowise returned HTTP ${res.status}.`);
+    }
+
+    return normaliseFlowiseResponse(res.json, res.body);
+  }
+
+  /** Health probe against the Flowise API root; used by /ready only. */
+  async ping(): Promise<{ ok: boolean; status: number; target: string }> {
+    const cfg = config();
+    const base = cfg.FLOWISE_BASE_URL || deriveBaseUrl(this.predictUrl);
+    if (!base) return { ok: false, status: 0, target: 'not-configured' };
+    const url = `${base.replace(/\/+$/, '')}/api/v1/ping`;
+    try {
+      const headers: Record<string, string> = {};
+      if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
+      const res = await request(url, { headers, label: 'flowise ping', timeoutMs: 5_000 });
+      return { ok: res.status < 400, status: res.status, target: safeLabel(url) };
+    } catch {
+      return { ok: false, status: 0, target: safeLabel(url) };
+    }
+  }
+}
+
+function deriveBaseUrl(predictUrl: string): string {
+  try {
+    const u = new URL(predictUrl);
+    return `${u.protocol}//${u.host}`;
+  } catch {
+    return '';
+  }
+}
+
+/**
+ * Flowise answers in several shapes depending on chatflow/version:
+ *  - a bare text body,
+ *  - `{ text | response | answer }`,
+ *  - the same plus `actions: [{ type: 'handoff' }]`.
+ */
+export function normaliseFlowiseResponse(json: unknown, rawBody: string): FlowiseResult {
+  if (json && typeof json === 'object' && !Array.isArray(json)) {
+    const obj = json as Record<string, unknown>;
+    const candidate = obj.text ?? obj.response ?? obj.answer;
+    const text = typeof candidate === 'string' ? candidate : null;
+    const actions = Array.isArray(obj.actions) ? (obj.actions as unknown[]) : null;
+
+    if (actions) {
+      const handoff = actions.some(
+        (a) =>
+          a !== null &&
+          typeof a === 'object' &&
+          (a as Record<string, unknown>).type === 'handoff',
+      );
+      if (handoff) return { type: 'handoff', text, actions };
+    }
+    if (text !== null && text.trim() !== '') return { type: 'reply', text, actions };
+  }
+
+  const body = rawBody.trim();
+  // A plain-text body is the answer itself, but only when it is not JSON we
+  // already failed to make sense of.
+  if (body !== '' && json === null) return { type: 'reply', text: body, actions: null };
+
+  return { type: 'unknown', text: null, actions: null };
+}

+ 79 - 0
src/clients/httpClient.ts

@@ -0,0 +1,79 @@
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { upstream } from '../errors.js';
+
+export interface HttpResponse<T = unknown> {
+  status: number;
+  body: string;
+  json: T | null;
+}
+
+export interface HttpRequestOptions {
+  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+  headers?: Record<string, string>;
+  json?: unknown;
+  timeoutMs?: number;
+  /** Label used in logs instead of the raw URL, so query secrets never leak. */
+  label?: string;
+}
+
+/**
+ * Thin fetch wrapper: JSON in, parsed JSON out, an explicit timeout, and
+ * upstream failures normalised into RelayError(502).
+ */
+export async function request<T = unknown>(
+  url: string,
+  opts: HttpRequestOptions = {},
+): Promise<HttpResponse<T>> {
+  const method = opts.method ?? 'GET';
+  const timeoutMs = opts.timeoutMs ?? config().HTTP_TIMEOUT_MS;
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+  const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
+  let body: string | undefined;
+  if (opts.json !== undefined) {
+    body = JSON.stringify(opts.json);
+    headers['Content-Type'] = 'application/json';
+  }
+
+  const label = opts.label ?? safeLabel(url);
+  const startedAt = Date.now();
+
+  try {
+    const res = await fetch(url, { method, headers, body, signal: controller.signal });
+    const text = await res.text();
+    let json: T | null = null;
+    try {
+      json = text ? (JSON.parse(text) as T) : null;
+    } catch {
+      json = null;
+    }
+    logger.debug('Upstream call finished', {
+      label,
+      method,
+      status: res.status,
+      ms: Date.now() - startedAt,
+    });
+    return { status: res.status, body: text, json };
+  } catch (err) {
+    const reason = err instanceof Error ? err.name : 'unknown';
+    logger.warn('Upstream call failed', { label, method, reason, ms: Date.now() - startedAt });
+    if (reason === 'AbortError') {
+      throw upstream('UPSTREAM_TIMEOUT', `Upstream ${label} timed out after ${timeoutMs}ms.`);
+    }
+    throw upstream('UPSTREAM_ERROR', `Upstream ${label} is unreachable.`);
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+/** host + path only — drops the query string, which may carry API credentials. */
+export function safeLabel(url: string): string {
+  try {
+    const u = new URL(url);
+    return `${u.host}${u.pathname}`;
+  } catch {
+    return 'invalid-url';
+  }
+}

+ 271 - 0
src/clients/wooClient.ts

@@ -0,0 +1,271 @@
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { notFound, upstream } from '../errors.js';
+import { request } from './httpClient.js';
+
+export type WooProduct = Record<string, unknown> & {
+  id?: number;
+  sku?: string;
+  name?: string;
+  status?: string;
+  catalog_visibility?: string;
+};
+export type WooOrder = Record<string, unknown>;
+
+export interface ShippingZone {
+  id: number;
+  name: string;
+}
+export interface ShippingLocation {
+  code?: string;
+  type?: string;
+}
+
+/**
+ * WooCommerce REST v3 client. Credentials travel as query parameters, which is
+ * the supported scheme over HTTPS; URLs are never logged verbatim.
+ */
+export class WooClient {
+  private readonly baseUrl: string;
+  private readonly ck: string;
+  private readonly cs: string;
+
+  constructor() {
+    const cfg = config();
+    this.baseUrl = cfg.WOOCOMMERCE_BASE_URL.replace(/\/+$/, '');
+    this.ck = cfg.WOOCOMMERCE_CONSUMER_KEY;
+    this.cs = cfg.WOOCOMMERCE_CONSUMER_SECRET;
+  }
+
+  private url(endpoint: string, params: Record<string, string | number> = {}): string {
+    const qs = new URLSearchParams();
+    for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
+    qs.set('consumer_key', this.ck);
+    qs.set('consumer_secret', this.cs);
+    return `${this.baseUrl}/wp-json/wc/v3/${endpoint.replace(/^\/+/, '')}?${qs.toString()}`;
+  }
+
+  private async get<T>(endpoint: string, params: Record<string, string | number> = {}): Promise<T> {
+    const res = await request<T>(this.url(endpoint, params), { label: `woo GET /${endpoint}` });
+    if (res.status >= 400) {
+      logger.warn('WooCommerce API error', { endpoint, status: res.status });
+      throw upstream('WOOCOMMERCE_API_ERROR', `WooCommerce returned HTTP ${res.status}.`);
+    }
+    return (res.json ?? ([] as unknown)) as T;
+  }
+
+  // ---------------------------------------------------------------- orders
+
+  /** Look up a single order: by number when given, otherwise the newest for an e-mail. */
+  async getOrder(opts: {
+    orderNumber?: string;
+    email?: string;
+    currency?: string;
+  }): Promise<WooOrder> {
+    const currencyParam: Record<string, string | number> = opts.currency
+      ? { currency: opts.currency }
+      : {};
+
+    if (opts.orderNumber) {
+      try {
+        return await this.get<WooOrder>(`orders/${encodeURIComponent(opts.orderNumber)}`, currencyParam);
+      } catch {
+        // Custom order-number plugins break the direct fetch — fall back to search.
+        const results = await this.get<WooOrder[]>('orders', {
+          search: opts.orderNumber,
+          per_page: 1,
+          ...currencyParam,
+        });
+        if (Array.isArray(results) && results.length > 0) return results[0] as WooOrder;
+        throw notFound('ORDER_NOT_FOUND', `Order #${opts.orderNumber} not found.`);
+      }
+    }
+
+    if (opts.email) {
+      const results = await this.getOrdersByEmail(opts.email, 5, opts.currency);
+      if (results.length === 0) {
+        throw notFound('ORDER_NOT_FOUND', 'No orders found for the provided e-mail address.');
+      }
+      return results[0] as WooOrder;
+    }
+
+    throw notFound('MISSING_PARAMS', 'Provide orderNumber or email.');
+  }
+
+  async getOrdersByEmail(email: string, limit = 5, currency?: string): Promise<WooOrder[]> {
+    const params: Record<string, string | number> = {
+      search: email,
+      per_page: limit,
+      orderby: 'date',
+      order: 'desc',
+    };
+    if (currency) params.currency = currency;
+    const res = await this.get<WooOrder[]>('orders', params);
+    return Array.isArray(res) ? res : [];
+  }
+
+  // -------------------------------------------------------------- products
+
+  async getProduct(opts: {
+    productId?: number;
+    sku?: string;
+    currency?: string;
+    lang?: string;
+  }): Promise<WooProduct> {
+    const params: Record<string, string | number> = {};
+    if (opts.currency) params.currency = opts.currency;
+    if (opts.lang) params.lang = opts.lang;
+
+    if (opts.productId && opts.productId > 0) {
+      const product = await this.get<WooProduct>(`products/${opts.productId}`, params);
+      if (product.status !== 'publish' || product.catalog_visibility === 'hidden') {
+        throw notFound('PRODUCT_NOT_FOUND', `Product #${opts.productId} is not available.`);
+      }
+      return product;
+    }
+
+    if (opts.sku) {
+      const results = await this.get<WooProduct[]>('products', {
+        ...params,
+        sku: opts.sku,
+        per_page: 1,
+        status: 'publish',
+      });
+      if (!Array.isArray(results) || results.length === 0) {
+        throw notFound('PRODUCT_NOT_FOUND', `Product with SKU ${opts.sku} not found.`);
+      }
+      return results[0] as WooProduct;
+    }
+
+    throw notFound('MISSING_PARAMS', 'Provide productId or sku.');
+  }
+
+  /**
+   * Search with the WPML fallback chain the PHP relay established:
+   *   1. search in the requested language;
+   *   2. retry without `lang` (WPML returns nothing when asked for the default);
+   *   3. `lang=all` across every translation, then re-fetch each hit by SKU in
+   *      the target language (SKUs are shared across translations).
+   */
+  async searchProducts(
+    query: string,
+    limit = 5,
+    currency = '',
+    lang = '',
+  ): Promise<WooProduct[]> {
+    const base: Record<string, string | number> = {
+      search: query,
+      per_page: limit,
+      status: 'publish',
+    };
+    if (currency) base.currency = currency;
+
+    let results = await this.get<WooProduct[]>('products', lang ? { ...base, lang } : base);
+    if (!Array.isArray(results)) results = [];
+
+    if (results.length === 0 && lang) {
+      const fallback = await this.get<WooProduct[]>('products', base);
+      if (Array.isArray(fallback)) results = fallback;
+    }
+
+    if (results.length === 0 && lang) {
+      let allFound: WooProduct[] = [];
+      try {
+        const r = await this.get<WooProduct[]>('products', { ...base, lang: 'all' });
+        if (Array.isArray(r)) allFound = r;
+      } catch {
+        // lang=all unsupported by this WPML version — skip the cross-language pass.
+      }
+
+      if (allFound.length > 0) {
+        const translated: WooProduct[] = [];
+        const seenSkus = new Set<string>();
+        for (const found of allFound) {
+          const sku = String(found.sku ?? '').trim();
+          if (!sku) {
+            translated.push(found);
+            continue;
+          }
+          if (seenSkus.has(sku)) continue;
+          seenSkus.add(sku);
+          try {
+            const params: Record<string, string | number> = { sku, per_page: 1, status: 'publish', lang };
+            if (currency) params.currency = currency;
+            const skuResult = await this.get<WooProduct[]>('products', params);
+            translated.push(Array.isArray(skuResult) && skuResult[0] ? skuResult[0] : found);
+          } catch {
+            translated.push(found);
+          }
+        }
+        if (translated.length > 0) results = translated;
+      }
+    }
+
+    return results.filter((p) => p.catalog_visibility !== 'hidden');
+  }
+
+  async getProductsByIds(ids: number[], lang = '', currency = ''): Promise<WooProduct[]> {
+    if (ids.length === 0) return [];
+    const params: Record<string, string | number> = {
+      include: ids.map((n) => Math.trunc(n)).join(','),
+      per_page: Math.min(ids.length, 100),
+      status: 'publish',
+    };
+    if (lang) params.lang = lang;
+    if (currency) params.currency = currency;
+    const res = await this.get<WooProduct[]>('products', params);
+    return Array.isArray(res) ? res : [];
+  }
+
+  // -------------------------------------------------------------- shipping
+
+  async getShippingZones(): Promise<ShippingZone[]> {
+    const res = await this.get<ShippingZone[]>('shipping/zones');
+    return Array.isArray(res) ? res : [];
+  }
+
+  async getShippingMethods(zoneId: number, currency = ''): Promise<Record<string, unknown>[]> {
+    const params: Record<string, string | number> = currency ? { currency } : {};
+    const res = await this.get<Record<string, unknown>[]>(`shipping/zones/${zoneId}/methods`, params);
+    return Array.isArray(res) ? res : [];
+  }
+
+  /** Zone 0 ("Rest of the World") legitimately has no locations. */
+  async getShippingZoneLocations(zoneId: number): Promise<ShippingLocation[]> {
+    try {
+      const res = await this.get<ShippingLocation[]>(`shipping/zones/${zoneId}/locations`);
+      return Array.isArray(res) ? res : [];
+    } catch {
+      return [];
+    }
+  }
+
+  // ------------------------------------------------------- payment methods
+
+  /** Requires admin-level consumer keys. */
+  async getPaymentGateways(): Promise<Record<string, unknown>[]> {
+    const res = await this.get<Record<string, unknown>[]>('payment_gateways');
+    return Array.isArray(res) ? res : [];
+  }
+
+  async ping(): Promise<{ ok: boolean; status: number; target: string }> {
+    try {
+      const res = await request(this.url('system_status/tools', { per_page: 1 }), {
+        label: 'woo ping',
+        timeoutMs: 8_000,
+      });
+      // 401/403 still proves the endpoint answers; only transport failure is "down".
+      return { ok: res.status < 500, status: res.status, target: `${hostOf(this.baseUrl)}/wp-json/wc/v3` };
+    } catch {
+      return { ok: false, status: 0, target: `${hostOf(this.baseUrl)}/wp-json/wc/v3` };
+    }
+  }
+}
+
+function hostOf(url: string): string {
+  try {
+    return new URL(url).host;
+  } catch {
+    return 'invalid-url';
+  }
+}

+ 94 - 0
src/clients/wpStoreClient.ts

@@ -0,0 +1,94 @@
+import { config } from '../config.js';
+import { upstream } from '../errors.js';
+import { request } from './httpClient.js';
+
+/**
+ * Client for the dedicated WordPress mu-plugin REST namespace `eksrelay/v1`
+ * (see `wp-plugins/eksrelay_api.php`): car data, product compatibility and
+ * per-currency shipping costs that the Woo REST API cannot express.
+ *
+ * WP_STORE_API_SECRET is sent as a bearer header when configured. The shipped
+ * mu-plugin still uses `permission_callback => __return_true`; hardening it to
+ * require this header is tracked in docs/DEPLOY.md.
+ */
+export class WpStoreClient {
+  private readonly base: string;
+  private readonly secret: string;
+
+  constructor() {
+    const cfg = config();
+    this.base = `${cfg.WOOCOMMERCE_BASE_URL.replace(/\/+$/, '')}/wp-json/eksrelay/v1`;
+    this.secret = cfg.WP_STORE_API_SECRET;
+  }
+
+  private headers(): Record<string, string> {
+    return this.secret ? { Authorization: `Bearer ${this.secret}` } : {};
+  }
+
+  async carData(payload: Record<string, unknown>): Promise<unknown> {
+    const res = await request<unknown>(`${this.base}/car-data`, {
+      method: 'POST',
+      json: payload,
+      headers: this.headers(),
+      label: 'wp-store POST /car-data',
+    });
+    if (res.status >= 400) {
+      throw upstream('WP_STORE_API_ERROR', `WP Store API returned HTTP ${res.status} for /car-data.`);
+    }
+    return res.json ?? res.body;
+  }
+
+  async productCompatibility(payload: Record<string, unknown>): Promise<unknown> {
+    const res = await request<unknown>(`${this.base}/product-compatibility`, {
+      method: 'POST',
+      json: payload,
+      headers: this.headers(),
+      label: 'wp-store POST /product-compatibility',
+    });
+    if (res.status >= 400) {
+      throw upstream(
+        'WP_STORE_API_ERROR',
+        `WP Store API returned HTTP ${res.status} for /product-compatibility.`,
+      );
+    }
+    return res.json ?? res.body;
+  }
+
+  async shippingCosts(zoneId: number, currency: string): Promise<unknown> {
+    const qs = new URLSearchParams({ zone_id: String(zoneId), currency });
+    const res = await request<unknown>(`${this.base}/shipping-costs?${qs.toString()}`, {
+      headers: this.headers(),
+      label: 'wp-store GET /shipping-costs',
+    });
+    if (res.status >= 400) {
+      throw upstream(
+        'WP_STORE_API_ERROR',
+        `WP Store API returned HTTP ${res.status} for /shipping-costs.`,
+      );
+    }
+    return res.json ?? res.body;
+  }
+
+  async ping(): Promise<{ ok: boolean; status: number; target: string }> {
+    try {
+      // zone 0 always exists in WooCommerce ("Rest of the World").
+      const res = await request(`${this.base}/shipping-costs?zone_id=0&currency=PLN`, {
+        headers: this.headers(),
+        label: 'wp-store ping',
+        timeoutMs: 8_000,
+      });
+      return { ok: res.status < 500, status: res.status, target: hostPath(this.base) };
+    } catch {
+      return { ok: false, status: 0, target: hostPath(this.base) };
+    }
+  }
+}
+
+function hostPath(url: string): string {
+  try {
+    const u = new URL(url);
+    return `${u.host}${u.pathname}`;
+  } catch {
+    return 'invalid-url';
+  }
+}

+ 100 - 0
src/config.ts

@@ -0,0 +1,100 @@
+import { z } from 'zod';
+
+/**
+ * Environment contract for the relay. Validation happens once at boot: a
+ * missing/typo'd variable must fail loudly at startup, never mid-conversation.
+ */
+/** `.default()` has to precede `.transform()` in zod v4, hence the factories. */
+const boolish = (def: string) =>
+  z
+    .string()
+    .default(def)
+    .transform((v) => ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase()));
+
+const csv = (def: string) =>
+  z
+    .string()
+    .default(def)
+    .transform((v) =>
+      v
+        .split(',')
+        .map((s) => s.trim().toUpperCase())
+        .filter(Boolean),
+    );
+
+const schema = z.object({
+  NODE_ENV: z.enum(['development', 'test', 'production']).default('production'),
+  PORT: z.coerce.number().int().positive().default(3000),
+  RELAY_MODE: z.enum(['dev', 'prod']).default('prod'),
+
+  DATABASE_URL: z.string().min(1).default('file:../data/eks_relay.db'),
+
+  // --- Chatwoot ---
+  CHATWOOT_BASE_URL: z.string().url(),
+  CHATWOOT_API_TOKEN: z.string().min(1),
+  CHATWOOT_ACCOUNT_ID: z.coerce.number().int().positive().default(1),
+  CHATWOOT_TICKET_LABEL: z.string().min(1).default('ticket'),
+  CHATWOOT_SPAM_LABEL: z.string().default('spam'),
+  CHATWOOT_APPLY_SPAM_LABEL: boolish('false'),
+  CHATWOOT_UNASSIGN_ON_TICKET: boolish('false'),
+  /// Webchat / API inbox — reserved for the later web widget adapter.
+  CHATWOOT_API_INBOX_ID: z.string().default(''),
+  CHATWOOT_API_IDENTITY_VALIDATION_TOKEN: z.string().default(''),
+
+  // --- Flowise ---
+  FLOWISE_PREDICT_URL: z.string().url(),
+  FLOWISE_API_KEY: z.string().default(''),
+  FLOWISE_BASE_URL: z.string().default(''),
+  FLOWISE_TIMEOUT_MS: z.coerce.number().int().positive().default(90_000),
+
+  // --- WooCommerce / WordPress ---
+  WOOCOMMERCE_BASE_URL: z.string().url(),
+  WOOCOMMERCE_CONSUMER_KEY: z.string().min(1),
+  WOOCOMMERCE_CONSUMER_SECRET: z.string().min(1),
+  WP_STORE_API_SECRET: z.string().default(''),
+  STORE_CURRENCIES: csv('PLN,EUR,AED,CZK,HUF,DKK,SEK,NOK,RON,BGN,GBP'),
+
+  // --- Relay auth ---
+  RELAY_SHARED_SECRET: z.string().min(8),
+  ADMIN_TOKEN: z.string().default(''),
+
+  // --- Behaviour ---
+  TENANT_ID: z.string().default('easyklima'),
+  DEFAULT_LANGUAGE: z.string().default('pl'),
+  TICKET_NUMBER_PREFIX: z.string().default('EKS'),
+  WORKER_ENABLED: boolish('true'),
+  WORKER_POLL_MS: z.coerce.number().int().positive().default(1_000),
+  WORKER_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3),
+  SPAM_GATE_ENABLED: boolish('true'),
+  HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000),
+
+  // --- Logging ---
+  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
+  LOG_PII: boolish('false'),
+});
+
+export type Config = z.infer<typeof schema>;
+
+let cached: Config | null = null;
+
+export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
+  const parsed = schema.safeParse(env);
+  if (!parsed.success) {
+    // Only names of the offending variables — never their values.
+    const issues = parsed.error.issues
+      .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
+      .join('; ');
+    throw new Error(`Invalid environment configuration: ${issues}`);
+  }
+  return parsed.data;
+}
+
+export function config(): Config {
+  cached ??= loadConfig();
+  return cached;
+}
+
+/** Test hook: inject a config without touching process.env. */
+export function setConfigForTests(cfg: Config): void {
+  cached = cfg;
+}

+ 188 - 0
src/domain/conversationPipeline.ts

@@ -0,0 +1,188 @@
+import { ChatwootClient } from '../clients/chatwootClient.js';
+import { FlowiseClient, type FlowisePayload } from '../clients/flowiseClient.js';
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { audit } from '../store/auditLog.js';
+import { setMessageStatus } from '../store/idempotencyStore.js';
+import { evaluateSpam } from './spamGate.js';
+import { createTicket, isTicketMode } from './ticketService.js';
+import type { SupportMessageEvent } from './messageNormalizer.js';
+
+export interface PipelineDeps {
+  chatwoot?: ChatwootClient;
+  flowise?: FlowiseClient;
+}
+
+export type PipelineOutcome =
+  | { action: 'skipped'; reason: string }
+  | { action: 'spam'; reason: string }
+  | { action: 'reply' }
+  | { action: 'handoff'; ticketNumber: string }
+  | { action: 'no_reply'; reason: string };
+
+/**
+ * The single decision path a normalised message goes through, regardless of
+ * which channel produced it:
+ *   ticket-mode check → spam gate → Flowise → reply or handoff.
+ */
+export async function processMessageEvent(
+  event: SupportMessageEvent,
+  deps: PipelineDeps = {},
+): Promise<PipelineOutcome> {
+  const cfg = config();
+  const chatwoot = deps.chatwoot ?? new ChatwootClient();
+  const flowise = deps.flowise ?? new FlowiseClient();
+
+  let labels = event.labels;
+  let customAttributes = event.customAttributes;
+  let additionalAttributes = event.additionalAttributes;
+
+  // The webhook payload often omits labels/custom attributes; without them the
+  // ticket-mode check would wrongly let the AI answer a handed-off conversation.
+  if (event.needsConversationFetch) {
+    logger.info('Fetching full conversation from Chatwoot', {
+      conversationId: event.conversationId,
+    });
+    const conv = await chatwoot.getConversation(event.conversationId);
+    labels = conv.labels ?? [];
+    customAttributes = conv.custom_attributes ?? {};
+    additionalAttributes = { ...additionalAttributes, ...(conv.additional_attributes ?? {}) };
+  }
+
+  if (isTicketMode({ labels, custom_attributes: customAttributes })) {
+    await setMessageStatus(event.source, event.messageId, 'skipped', 'ticket_mode');
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'skipped_ticket_mode',
+      summary: 'Conversation is in manual/ticket mode — Flowise not called',
+    });
+    return { action: 'skipped', reason: 'ticket_mode' };
+  }
+
+  if (cfg.SPAM_GATE_ENABLED) {
+    const verdict = evaluateSpam({ ...event, additionalAttributes });
+    if (verdict.spam) {
+      await setMessageStatus(event.source, event.messageId, 'spam', verdict.reason);
+      await audit({
+        conversationId: event.conversationId,
+        messageId: event.messageId,
+        eventType: 'spam_blocked',
+        summary: `Blocked before Flowise: ${verdict.reason}`,
+      });
+      if (cfg.CHATWOOT_APPLY_SPAM_LABEL && cfg.CHATWOOT_SPAM_LABEL) {
+        try {
+          await chatwoot.addLabel(event.conversationId, cfg.CHATWOOT_SPAM_LABEL);
+        } catch {
+          logger.warn('Could not apply spam label (label may not exist in Chatwoot)', {
+            conversationId: event.conversationId,
+          });
+        }
+      }
+      return { action: 'spam', reason: verdict.reason };
+    }
+  }
+
+  const payload = buildFlowisePayload(event);
+  const response = await flowise.predict(payload);
+
+  await audit({
+    conversationId: event.conversationId,
+    messageId: event.messageId,
+    eventType: 'flowise_response',
+    summary: `Flowise responded with type=${response.type}`,
+    meta: { hasText: response.text !== null, actions: response.actions?.length ?? 0 },
+  });
+
+  if (response.type === 'handoff') {
+    const ticket = await createTicket(event.conversationId, 'flowise_handoff_action', chatwoot);
+    let reply =
+      response.text ??
+      `Twoje zgłoszenie zostało utworzone. Numer ticketu: ${ticket.ticketNumber}. Nasz zespół wkrótce się z Tobą skontaktuje.`;
+    if (response.text !== null && !response.text.includes(ticket.ticketNumber)) {
+      reply += `\n\nNumer ticketu: ${ticket.ticketNumber}`;
+    }
+    await chatwoot.sendOutgoingMessage(event.conversationId, reply);
+    await setMessageStatus(event.source, event.messageId, 'ticket', 'flowise_handoff');
+    return { action: 'handoff', ticketNumber: ticket.ticketNumber };
+  }
+
+  if (response.type === 'reply' && response.text && response.text.trim() !== '') {
+    // Deliberately no second ticket-mode check: if the agent created a ticket
+    // through /tools/new_ticket during this very call, its confirmation message
+    // still has to reach the customer. Later messages are stopped by the check
+    // at the top of this function.
+    await chatwoot.sendOutgoingMessage(event.conversationId, response.text);
+    const becameTicket = await wasTicketedDuringCall(event.conversationId);
+    await setMessageStatus(
+      event.source,
+      event.messageId,
+      becameTicket ? 'ticket' : 'replied',
+      becameTicket ? 'tool_new_ticket' : undefined,
+    );
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'reply_sent',
+      summary: becameTicket
+        ? 'AI reply sent; conversation was ticketed during the call'
+        : 'AI reply sent to customer',
+    });
+    return { action: 'reply' };
+  }
+
+  await setMessageStatus(event.source, event.messageId, 'failed', 'empty_flowise_response');
+  await audit({
+    conversationId: event.conversationId,
+    messageId: event.messageId,
+    eventType: 'no_reply',
+    summary: 'Flowise returned no usable answer',
+  });
+  return { action: 'no_reply', reason: 'empty_flowise_response' };
+}
+
+async function wasTicketedDuringCall(conversationId: number): Promise<boolean> {
+  const { db } = await import('../store/db.js');
+  const t = await db().ticket.findUnique({ where: { conversationId } });
+  return t !== null;
+}
+
+/**
+ * Flowise payload. `[CONTACT_INFO]` is a contract with the deployed custom
+ * tools: `get_order_data` parses `contact_email:` out of `$flow.input`.
+ */
+export function buildFlowisePayload(event: SupportMessageEvent): FlowisePayload {
+  const cfg = config();
+
+  const contactLines: string[] = ['[CONTACT_INFO]'];
+  if (event.senderEmail) {
+    contactLines.push(`contact_email: ${event.senderEmail}`);
+    contactLines.push(`Email klienta: ${event.senderEmail}`);
+  }
+  if (event.senderName) contactLines.push(`Imię klienta: ${event.senderName}`);
+  contactLines.push('[/CONTACT_INFO]');
+
+  const question = `${contactLines.join('\n')}\n\n---\n${event.content}`;
+
+  return {
+    question,
+    overrideConfig: {
+      sessionId: `chatwoot:${event.conversationId}`,
+      conversationId: event.conversationId,
+      inboxId: event.inboxId,
+      messageId: event.messageId,
+      tenantId: cfg.TENANT_ID,
+      channel: event.channel,
+      language: cfg.DEFAULT_LANGUAGE,
+    },
+    metadata: {
+      source: event.source,
+      event: 'message_created',
+      messageType: 'incoming',
+      sender: { id: event.senderId, email: event.senderEmail || null, name: event.senderName || null },
+      labels: event.labels,
+      subject: event.subject,
+      attachments: event.attachmentCount,
+    },
+  };
+}

+ 98 - 0
src/domain/formatters.ts

@@ -0,0 +1,98 @@
+import type { WooOrder, WooProduct } from '../clients/wooClient.js';
+
+function str(v: unknown, fallback = ''): string {
+  return v === null || v === undefined ? fallback : String(v);
+}
+
+function obj(v: unknown): Record<string, unknown> {
+  return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
+}
+
+function arr(v: unknown): Record<string, unknown>[] {
+  return Array.isArray(v) ? (v as Record<string, unknown>[]) : [];
+}
+
+export function stripTags(html: string): string {
+  return html
+    .replace(/<[^>]*>/g, ' ')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/\s{2,}/g, ' ')
+    .trim();
+}
+
+/** Curated order view for the agent — deliberately no internal/admin fields. */
+export function formatOrder(order: WooOrder): Record<string, unknown> {
+  const billing = obj(order.billing);
+  const shipping = obj(order.shipping);
+
+  return {
+    id: order.id ?? null,
+    number: order.number ?? null,
+    status: order.status ?? null,
+    date_created: order.date_created ?? null,
+    total: order.total ?? null,
+    currency: order.currency ?? null,
+    billing: {
+      first_name: str(billing.first_name),
+      last_name: str(billing.last_name),
+      email: str(billing.email),
+      phone: str(billing.phone),
+      city: str(billing.city),
+      country: str(billing.country),
+    },
+    shipping: {
+      first_name: str(shipping.first_name),
+      last_name: str(shipping.last_name),
+      city: str(shipping.city),
+      country: str(shipping.country),
+      address_1: str(shipping.address_1),
+    },
+    payment_method: order.payment_method_title ?? null,
+    shipping_total: order.shipping_total ?? null,
+    line_items: arr(order.line_items).map((item) => ({
+      name: str(item.name),
+      sku: str(item.sku),
+      quantity: item.quantity ?? 0,
+      total: str(item.total, '0'),
+    })),
+    shipping_lines: arr(order.shipping_lines).map((sl) => ({
+      method_title: str(sl.method_title),
+      total: str(sl.total, '0'),
+    })),
+    customer_note: str(order.customer_note),
+  };
+}
+
+export function formatProduct(product: WooProduct): Record<string, unknown> {
+  return {
+    id: product.id ?? null,
+    name: str(product.name),
+    sku: str(product.sku),
+    slug: str(product.slug),
+    status: str(product.status),
+    price: str(product.price),
+    regular_price: str(product.regular_price),
+    sale_price: str(product.sale_price),
+    stock_status: str(product.stock_status),
+    stock_quantity: product.stock_quantity ?? null,
+    short_description: stripTags(str(product.short_description)),
+    categories: arr(product.categories).map((c) => str(c.name)),
+    attributes: arr(product.attributes).map((a) => ({
+      name: str(a.name),
+      options: Array.isArray(a.options) ? a.options : [],
+    })),
+    permalink: str(product.permalink),
+  };
+}
+
+export function formatPaymentGateway(g: Record<string, unknown>): Record<string, unknown> {
+  return {
+    id: str(g.id),
+    title: str(g.title),
+    description: stripTags(str(g.description)),
+    enabled: g.enabled ?? false,
+  };
+}

+ 157 - 0
src/domain/messageNormalizer.ts

@@ -0,0 +1,157 @@
+import { createHash } from 'node:crypto';
+import type { ChatwootWebhookPayload } from '../types/chatwoot.js';
+
+/**
+ * Channel-agnostic event the rest of the pipeline works on. New channels
+ * (webchat, Allegro, …) only need to produce this shape.
+ */
+export interface SupportMessageEvent {
+  source: 'chatwoot';
+  channel: string;
+  conversationId: number;
+  messageId: string;
+  inboxId: number;
+  content: string;
+  subject: string | null;
+  senderEmail: string;
+  senderName: string;
+  senderId: string | null;
+  labels: string[];
+  customAttributes: Record<string, unknown>;
+  additionalAttributes: Record<string, unknown>;
+  attachmentCount: number;
+  /** true when the webhook did not carry labels/custom attributes. */
+  needsConversationFetch: boolean;
+}
+
+export type NormalizeResult =
+  | { ok: true; event: SupportMessageEvent }
+  | { ok: false; reason: string };
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
+
+/** Strips quoted replies, signatures and HTML so the LLM sees only new text. */
+export function cleanEmailBody(raw: string): string {
+  let text = raw.replace(/\r\n/g, '\n');
+
+  if (/<[a-z][\s\S]*>/i.test(text)) {
+    text = text
+      .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+      .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+      .replace(/<br\s*\/?>/gi, '\n')
+      .replace(/<\/p>/gi, '\n')
+      .replace(/<[^>]+>/g, ' ');
+  }
+
+  const cutMarkers = [
+    /^\s*-{2,}\s*Original Message\s*-{2,}/im,
+    /^\s*-{2,}\s*Wiadomość oryginalna\s*-{2,}/im,
+    /^\s*_{5,}\s*$/m,
+    /^\s*(On|W dniu)\b.*\b(wrote|napisał|napisała|pisze):\s*$/im,
+  ];
+  for (const marker of cutMarkers) {
+    const m = marker.exec(text);
+    if (m && m.index > 0) text = text.slice(0, m.index);
+  }
+
+  return text
+    .split('\n')
+    .filter((line) => !/^\s*>/.test(line))
+    .join('\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+function firstEmail(...candidates: (string | undefined | null)[]): string {
+  for (const c of candidates) {
+    const v = (c ?? '').trim();
+    if (v && EMAIL_RE.test(v)) return v.toLowerCase();
+  }
+  return '';
+}
+
+/**
+ * Turns a raw Chatwoot webhook into a SupportMessageEvent, or explains why the
+ * payload is not something the AI pipeline should act on.
+ */
+export function normalizeChatwootWebhook(payload: ChatwootWebhookPayload): NormalizeResult {
+  if (payload.event !== 'message_created') {
+    return { ok: false, reason: `unsupported_event:${payload.event ?? 'none'}` };
+  }
+  if (payload.message_type !== 'incoming') {
+    return { ok: false, reason: `not_incoming:${payload.message_type ?? 'none'}` };
+  }
+  if (payload.private === true) {
+    return { ok: false, reason: 'private_note' };
+  }
+
+  const conversation = payload.conversation ?? {};
+  const conversationId = Number(conversation.id ?? 0);
+  if (!Number.isFinite(conversationId) || conversationId <= 0) {
+    return { ok: false, reason: 'missing_conversation_id' };
+  }
+
+  const rawContent = typeof payload.content === 'string' ? payload.content : '';
+  const content = cleanEmailBody(rawContent);
+  const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
+
+  if (content === '' && attachments.length === 0) {
+    return { ok: false, reason: 'empty_message' };
+  }
+
+  const metaSender = conversation.meta?.sender;
+  const senderEmail = firstEmail(
+    metaSender?.email,
+    payload.sender?.email,
+    conversation.contact_inbox?.source_id,
+    payload.source_id ?? undefined,
+  );
+  const senderName = String(metaSender?.name ?? payload.sender?.name ?? '').trim();
+  const senderIdRaw = metaSender?.id ?? payload.sender?.id;
+
+  const labels = Array.isArray(conversation.labels) ? conversation.labels : [];
+  const customAttributes = conversation.custom_attributes ?? {};
+  const additionalAttributes = conversation.additional_attributes ?? {};
+
+  const messageId =
+    payload.id !== undefined && payload.id !== null
+      ? String(payload.id)
+      : // No message id (older Chatwoot / some channels): derive a stable
+        // surrogate so retries still deduplicate.
+        `hash:${conversationId}:${createHash('sha256').update(rawContent).digest('hex').slice(0, 32)}`;
+
+  const subject =
+    (additionalAttributes.mail_subject as string | undefined) ??
+    (additionalAttributes.subject as string | undefined) ??
+    null;
+
+  return {
+    ok: true,
+    event: {
+      source: 'chatwoot',
+      channel: mapChannel(conversation.channel),
+      conversationId,
+      messageId,
+      inboxId: Number(conversation.inbox_id ?? payload.inbox?.id ?? 0),
+      content,
+      subject,
+      senderEmail,
+      senderName,
+      senderId: senderIdRaw !== undefined && senderIdRaw !== null ? String(senderIdRaw) : null,
+      labels,
+      customAttributes,
+      additionalAttributes,
+      attachmentCount: attachments.length,
+      needsConversationFetch:
+        conversation.labels === undefined || conversation.custom_attributes === undefined,
+    },
+  };
+}
+
+function mapChannel(channel?: string): string {
+  if (!channel) return 'email';
+  if (channel.includes('Email')) return 'email';
+  if (channel.includes('Api')) return 'api';
+  if (channel.includes('WebWidget')) return 'webchat';
+  return channel.replace('Channel::', '').toLowerCase();
+}

+ 102 - 0
src/domain/spamGate.ts

@@ -0,0 +1,102 @@
+import type { SupportMessageEvent } from './messageNormalizer.js';
+
+export interface SpamVerdict {
+  spam: boolean;
+  /** Stable machine-readable rule id, safe to store and to show in /admin/events. */
+  reason: string;
+}
+
+const CLEAN: SpamVerdict = { spam: false, reason: '' };
+
+/** Local-parts that only ever belong to automated senders. */
+const AUTOMATED_LOCAL_PARTS = [
+  'mailer-daemon',
+  'postmaster',
+  'no-reply',
+  'noreply',
+  'donotreply',
+  'do-not-reply',
+  'bounce',
+  'bounces',
+  'notification',
+  'notifications',
+  'newsletter',
+  'mailer',
+  'automailer',
+];
+
+const BOUNCE_SUBJECT_RE =
+  /(undelivered mail|delivery status notification|mail delivery (failed|subsystem)|returned mail|delivery failure|nie dostarczono|niedostarczona wiadomość|failure notice)/i;
+
+const AUTOREPLY_SUBJECT_RE =
+  /(out of office|automatic reply|auto[- ]?reply|autoreply|abwesenheit|automatyczna odpowied[źz]|urlop|nieobecno[śs][ćc])/i;
+
+const NEWSLETTER_SUBJECT_RE =
+  /(unsubscribe|newsletter|wypisz si[ęe]|promocja tygodnia|black friday|rabat -?\d+%)/i;
+
+const DMARC_REPORT_RE = /(report domain:|dmarc aggregate report|forensic report)/i;
+
+/** Marketing/spam-scored headers Chatwoot copies into additional_attributes. */
+const AUTO_HEADER_KEYS = [
+  'auto-submitted',
+  'x-autoreply',
+  'x-autorespond',
+  'precedence',
+  'list-unsubscribe',
+  'x-spam-flag',
+];
+
+/**
+ * Cheap rule-based filter in front of Flowise/OpenAI. Deliberately conservative:
+ * it only rejects patterns that no real customer question matches, because a
+ * false positive means a silently ignored customer.
+ */
+export function evaluateSpam(event: SupportMessageEvent): SpamVerdict {
+  const subject = (event.subject ?? '').trim();
+  const content = event.content.trim();
+
+  if (content === '' && event.attachmentCount > 0) {
+    return { spam: true, reason: 'attachment_only' };
+  }
+  if (content === '') {
+    return { spam: true, reason: 'empty_body' };
+  }
+
+  const localPart = event.senderEmail.split('@')[0]?.toLowerCase() ?? '';
+  if (localPart && AUTOMATED_LOCAL_PARTS.some((p) => localPart === p || localPart.startsWith(`${p}+`))) {
+    return { spam: true, reason: `automated_sender:${localPart}` };
+  }
+
+  if (BOUNCE_SUBJECT_RE.test(subject)) return { spam: true, reason: 'bounce_subject' };
+  if (AUTOREPLY_SUBJECT_RE.test(subject)) return { spam: true, reason: 'autoreply_subject' };
+  if (DMARC_REPORT_RE.test(subject)) return { spam: true, reason: 'dmarc_report' };
+  if (NEWSLETTER_SUBJECT_RE.test(subject)) return { spam: true, reason: 'newsletter_subject' };
+
+  const headers = normaliseHeaders(event.additionalAttributes);
+  for (const key of AUTO_HEADER_KEYS) {
+    const value = headers[key];
+    if (value === undefined) continue;
+    if (key === 'precedence' && !/^(bulk|junk|list)$/i.test(value)) continue;
+    if (key === 'auto-submitted' && /^no$/i.test(value)) continue;
+    if (key === 'x-spam-flag' && !/^yes$/i.test(value)) continue;
+    return { spam: true, reason: `header:${key}` };
+  }
+
+  // A body with no letters at all (only links/punctuation) is not a question.
+  if (!/\p{L}{3,}/u.test(content)) {
+    return { spam: true, reason: 'no_textual_content' };
+  }
+
+  return CLEAN;
+}
+
+function normaliseHeaders(attrs: Record<string, unknown>): Record<string, string> {
+  const out: Record<string, string> = {};
+  const raw = (attrs.email as Record<string, unknown> | undefined) ?? attrs;
+  const headers = (raw?.headers as Record<string, unknown> | undefined) ?? raw;
+  if (!headers || typeof headers !== 'object') return out;
+  for (const [k, v] of Object.entries(headers)) {
+    if (typeof v === 'string' || typeof v === 'number') out[k.toLowerCase()] = String(v);
+  }
+  return out;
+}

+ 109 - 0
src/domain/storeLocales.ts

@@ -0,0 +1,109 @@
+import { config } from '../config.js';
+
+/**
+ * Maps WPML language codes to the currency a customer writing in that language
+ * would expect, then narrows that to what the shop's multicurrency plugin
+ * actually supports (EUR is the fallback).
+ */
+const CURRENCY_MAP: Record<string, string> = {
+  aed: 'AED',
+  be: 'BYN',
+  bg: 'BGN',
+  hr: 'EUR',
+  cs: 'CZK',
+  da: 'DKK',
+  nl: 'EUR',
+  en: 'EUR',
+  et: 'EUR',
+  fi: 'EUR',
+  fr: 'EUR',
+  de: 'EUR',
+  el: 'EUR',
+  hu: 'HUF',
+  it: 'EUR',
+  lv: 'EUR',
+  lt: 'EUR',
+  no: 'NOK',
+  pl: 'PLN',
+  'pt-pt': 'EUR',
+  ro: 'RON',
+  sk: 'EUR',
+  sl: 'EUR',
+  es: 'EUR',
+  sv: 'SEK',
+  tr: 'TRY',
+  uk: 'UAH',
+};
+
+export const FALLBACK_CURRENCY = 'EUR';
+
+export interface ResolvedLocale {
+  currency: string;
+  fallback: boolean;
+  reason?: 'unknown_language' | 'unsupported_currency';
+  naturalCurrency?: string;
+}
+
+function supportedCurrencies(): string[] {
+  return config().STORE_CURRENCIES;
+}
+
+export function isSupportedCurrency(currency: string): boolean {
+  return supportedCurrencies().includes(currency.toUpperCase());
+}
+
+export function resolveLocale(language?: string | null): ResolvedLocale {
+  if (!language) return { currency: FALLBACK_CURRENCY, fallback: false };
+
+  const lang = language.toLowerCase().trim();
+  const natural = CURRENCY_MAP[lang];
+
+  if (!natural) {
+    return { currency: FALLBACK_CURRENCY, fallback: true, reason: 'unknown_language' };
+  }
+  if (!isSupportedCurrency(natural)) {
+    return {
+      currency: FALLBACK_CURRENCY,
+      fallback: true,
+      reason: 'unsupported_currency',
+      naturalCurrency: natural,
+    };
+  }
+  return { currency: natural, fallback: false };
+}
+
+/**
+ * An explicit `currency` argument wins over the language mapping, but only when
+ * the shop really sells in it.
+ */
+export function resolveCurrency(
+  language?: string | null,
+  currencyOverride?: string | null,
+): ResolvedLocale {
+  const override = currencyOverride?.toUpperCase().trim();
+  if (override && isSupportedCurrency(override)) {
+    return { currency: override, fallback: false };
+  }
+  return resolveLocale(language);
+}
+
+export function currencyNote(locale: ResolvedLocale, language?: string | null): string {
+  if (locale.reason === 'unknown_language') {
+    return `Unknown language code ${language ?? '?'}. Prices shown in ${FALLBACK_CURRENCY}.`;
+  }
+  const natural = locale.naturalCurrency ?? language ?? '?';
+  return `Currency for ${natural} is not supported in the store. Prices shown in ${FALLBACK_CURRENCY}.`;
+}
+
+/** Adds `currency` / `currency_fallback` / `currency_note` exactly as the Flowise tools expect. */
+export function currencyMeta(
+  locale: ResolvedLocale,
+  language?: string | null,
+): Record<string, unknown> {
+  const meta: Record<string, unknown> = { currency: locale.currency };
+  if (locale.fallback) {
+    meta.currency_fallback = true;
+    meta.currency_note = currencyNote(locale, language);
+  }
+  return meta;
+}

+ 128 - 0
src/domain/ticketService.ts

@@ -0,0 +1,128 @@
+import { ChatwootClient, type ChatwootConversation } from '../clients/chatwootClient.js';
+import { config } from '../config.js';
+import { db } from '../store/db.js';
+import { audit } from '../store/auditLog.js';
+import { logger } from '../logger.js';
+
+export interface TicketResult {
+  ok: true;
+  ticketNumber: string;
+  status: 'created' | 'existing';
+}
+
+/** `EKS-YYYYMMDD-<conversationId>` — readable, sortable and stable per conversation. */
+export function formatTicketNumber(conversationId: number, now = new Date()): string {
+  const prefix = config().TICKET_NUMBER_PREFIX;
+  const y = now.getUTCFullYear();
+  const m = String(now.getUTCMonth() + 1).padStart(2, '0');
+  const d = String(now.getUTCDate()).padStart(2, '0');
+  return `${prefix}-${y}${m}${d}-${conversationId}`;
+}
+
+/** A conversation is "manual" once it carries the ticket label, a ticket number, or handoff=true. */
+export function isTicketMode(conv: Partial<ChatwootConversation>): boolean {
+  const label = config().CHATWOOT_TICKET_LABEL;
+  const labels = Array.isArray(conv.labels) ? conv.labels : [];
+  if (labels.includes(label)) return true;
+
+  const ca = conv.custom_attributes ?? {};
+  const handoff = ca.handoff;
+  if (handoff === true || handoff === 'true') return true;
+
+  const ticketNumber = ca.ticket_number;
+  if (
+    ticketNumber !== undefined &&
+    ticketNumber !== null &&
+    String(ticketNumber).trim() !== '' &&
+    String(ticketNumber).trim() !== '0'
+  ) {
+    return true;
+  }
+
+  return false;
+}
+
+/**
+ * Create (or return) the ticket for a conversation.
+ *
+ * Idempotency has two layers: the local Ticket table keyed by conversationId,
+ * and a re-read of the live Chatwoot attributes — so a ticket created by
+ * someone else in the panel is adopted rather than duplicated.
+ */
+export async function createTicket(
+  conversationId: number,
+  reason?: string,
+  chatwoot = new ChatwootClient(),
+): Promise<TicketResult> {
+  const cfg = config();
+
+  const local = await db().ticket.findUnique({ where: { conversationId } });
+  if (local) {
+    logger.info('Ticket already recorded locally', { conversationId });
+    await ensureChatwootTicketState(chatwoot, conversationId, local.ticketNumber);
+    return { ok: true, ticketNumber: local.ticketNumber, status: 'existing' };
+  }
+
+  const conv = await chatwoot.getConversation(conversationId);
+  const existingNumber = String(conv.custom_attributes?.ticket_number ?? '').trim();
+
+  if (existingNumber && existingNumber !== '0') {
+    await db().ticket.create({
+      data: { conversationId, ticketNumber: existingNumber, reason: reason ?? 'adopted_existing' },
+    });
+    await ensureChatwootTicketState(chatwoot, conversationId, existingNumber);
+    await audit({
+      conversationId,
+      eventType: 'ticket_adopted',
+      summary: `Adopted existing ticket ${existingNumber}`,
+    });
+    return { ok: true, ticketNumber: existingNumber, status: 'existing' };
+  }
+
+  const ticketNumber = formatTicketNumber(conversationId);
+
+  await chatwoot.setCustomAttributes(conversationId, {
+    ticket_number: ticketNumber,
+    handoff: true,
+    ...(reason ? { handoff_reason: reason.slice(0, 200) } : {}),
+  });
+  await chatwoot.addLabel(conversationId, cfg.CHATWOOT_TICKET_LABEL);
+
+  if (cfg.CHATWOOT_UNASSIGN_ON_TICKET) {
+    await chatwoot.unassignConversation(conversationId);
+  }
+
+  await db().ticket.create({
+    data: { conversationId, ticketNumber, reason: reason ?? null },
+  });
+
+  await audit({
+    conversationId,
+    eventType: 'ticket_created',
+    summary: `Ticket ${ticketNumber} created`,
+    meta: { reason: reason ?? null, unassigned: cfg.CHATWOOT_UNASSIGN_ON_TICKET },
+  });
+
+  logger.info('Ticket created', { conversationId, ticketNumber });
+  return { ok: true, ticketNumber, status: 'created' };
+}
+
+/** Re-apply label/attributes when a locally known ticket lost them in Chatwoot. */
+async function ensureChatwootTicketState(
+  chatwoot: ChatwootClient,
+  conversationId: number,
+  ticketNumber: string,
+): Promise<void> {
+  try {
+    const conv = await chatwoot.getConversation(conversationId);
+    if (String(conv.custom_attributes?.ticket_number ?? '') !== ticketNumber) {
+      await chatwoot.setCustomAttributes(conversationId, {
+        ticket_number: ticketNumber,
+        handoff: true,
+      });
+    }
+    await chatwoot.addLabel(conversationId, config().CHATWOOT_TICKET_LABEL);
+  } catch {
+    logger.warn('Could not re-assert ticket state in Chatwoot', { conversationId });
+  }
+}

+ 27 - 0
src/errors.ts

@@ -0,0 +1,27 @@
+/** Error carrying an HTTP status and a stable machine-readable code. */
+export class RelayError extends Error {
+  constructor(
+    readonly httpCode: number,
+    readonly code: string,
+    message: string,
+    readonly details?: Record<string, unknown>,
+  ) {
+    super(message);
+    this.name = 'RelayError';
+  }
+
+  toJSON(): Record<string, unknown> {
+    return {
+      ok: false,
+      code: this.code,
+      message: this.message,
+      ...(this.details ? { details: this.details } : {}),
+    };
+  }
+}
+
+export const badRequest = (code: string, message: string) => new RelayError(400, code, message);
+export const unauthorized = (message = 'Invalid or missing credentials.') =>
+  new RelayError(401, 'UNAUTHORIZED', message);
+export const notFound = (code: string, message: string) => new RelayError(404, code, message);
+export const upstream = (code: string, message: string) => new RelayError(502, code, message);

+ 27 - 0
src/http/app.ts

@@ -0,0 +1,27 @@
+import express from 'express';
+import { healthRouter } from './routes/health.js';
+import { webhookRouter } from './routes/webhooks.js';
+import { toolsRouter } from './routes/tools.js';
+import { adminRouter } from './routes/admin.js';
+import { requestLog } from './middleware/requestLog.js';
+import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
+
+export function createApp(): express.Express {
+  const app = express();
+
+  app.disable('x-powered-by');
+  // Traefik terminates TLS; trust its forwarded headers for correct client IPs.
+  app.set('trust proxy', true);
+  app.use(express.json({ limit: '2mb' }));
+  app.use(requestLog);
+
+  app.use(healthRouter);
+  app.use(webhookRouter);
+  app.use(toolsRouter);
+  app.use(adminRouter);
+
+  app.use(notFoundHandler);
+  app.use(errorHandler);
+
+  return app;
+}

+ 45 - 0
src/http/middleware/auth.ts

@@ -0,0 +1,45 @@
+import { timingSafeEqual } from 'node:crypto';
+import type { NextFunction, Request, Response } from 'express';
+import { config } from '../../config.js';
+import { unauthorized } from '../../errors.js';
+
+function safeEquals(a: string, b: string): boolean {
+  const ba = Buffer.from(a);
+  const bb = Buffer.from(b);
+  if (ba.length !== bb.length) return false;
+  return timingSafeEqual(ba, bb);
+}
+
+export function bearerToken(req: Request): string {
+  const header = req.header('authorization') ?? '';
+  return header.startsWith('Bearer ') ? header.slice(7).trim() : '';
+}
+
+/** Guards every /tools/* route with RELAY_SHARED_SECRET. */
+export function requireToolAuth(req: Request, _res: Response, next: NextFunction): void {
+  const token = bearerToken(req);
+  const secret = config().RELAY_SHARED_SECRET;
+  if (!token || !secret || !safeEquals(secret, token)) {
+    next(unauthorized('Invalid or missing relay shared secret.'));
+    return;
+  }
+  next();
+}
+
+/**
+ * Guards /admin/*. When ADMIN_TOKEN is empty the route is closed entirely
+ * rather than left open — an unset token must never mean "no auth".
+ */
+export function requireAdminAuth(req: Request, _res: Response, next: NextFunction): void {
+  const token = bearerToken(req);
+  const secret = config().ADMIN_TOKEN;
+  if (!secret) {
+    next(unauthorized('Admin endpoints are disabled (ADMIN_TOKEN is not set).'));
+    return;
+  }
+  if (!token || !safeEquals(secret, token)) {
+    next(unauthorized('Invalid or missing admin token.'));
+    return;
+  }
+  next();
+}

+ 36 - 0
src/http/middleware/errorHandler.ts

@@ -0,0 +1,36 @@
+import type { NextFunction, Request, Response } from 'express';
+import { RelayError } from '../../errors.js';
+import { logger } from '../../logger.js';
+
+export function notFoundHandler(req: Request, res: Response): void {
+  res.status(404).json({
+    ok: false,
+    code: 'NOT_FOUND',
+    message: `No route for ${req.method} ${req.path}`,
+  });
+}
+
+export function errorHandler(
+  err: unknown,
+  req: Request,
+  res: Response,
+  _next: NextFunction,
+): void {
+  if (err instanceof RelayError) {
+    logger.warn('Request failed', {
+      path: req.path,
+      code: err.code,
+      status: err.httpCode,
+    });
+    res.status(err.httpCode).json(err.toJSON());
+    return;
+  }
+
+  const message = err instanceof Error ? err.message : String(err);
+  logger.error('Unhandled error', { path: req.path, error: message });
+  res.status(500).json({
+    ok: false,
+    code: 'INTERNAL_ERROR',
+    message: 'An unexpected error occurred.',
+  });
+}

+ 29 - 0
src/http/middleware/requestLog.ts

@@ -0,0 +1,29 @@
+import { randomUUID } from 'node:crypto';
+import type { NextFunction, Request, Response } from 'express';
+import { logger } from '../../logger.js';
+
+declare module 'express-serve-static-core' {
+  interface Request {
+    requestId?: string;
+  }
+}
+
+/** Logs method/path/status/duration only — never bodies, never headers. */
+export function requestLog(req: Request, res: Response, next: NextFunction): void {
+  const id = randomUUID();
+  req.requestId = id;
+  res.setHeader('X-Request-Id', id);
+  const startedAt = Date.now();
+
+  res.on('finish', () => {
+    logger.info('http', {
+      requestId: id,
+      method: req.method,
+      path: req.path,
+      status: res.statusCode,
+      ms: Date.now() - startedAt,
+    });
+  });
+
+  next();
+}

+ 79 - 0
src/http/routes/admin.ts

@@ -0,0 +1,79 @@
+import { Router } from 'express';
+import { requireAdminAuth } from '../middleware/auth.js';
+import { recentEvents } from '../../store/auditLog.js';
+import { queueStats } from '../../queue/jobQueue.js';
+import { db } from '../../store/db.js';
+
+export const adminRouter = Router();
+
+adminRouter.use('/admin', requireAdminAuth);
+
+/** Recent audit trail. Metadata was redacted on write, so nothing secret can surface here. */
+adminRouter.get('/admin/events', async (req, res, next) => {
+  try {
+    const limit = Number(req.query.limit ?? 50);
+    const conversationId = req.query.conversationId
+      ? Number(req.query.conversationId)
+      : undefined;
+
+    const events = await recentEvents(
+      Number.isFinite(limit) ? limit : 50,
+      conversationId && Number.isFinite(conversationId) ? conversationId : undefined,
+    );
+
+    res.json({
+      ok: true,
+      count: events.length,
+      events: events.map((e) => ({
+        id: e.id,
+        conversationId: e.conversationId,
+        messageId: e.messageId,
+        eventType: e.eventType,
+        summary: e.summary,
+        meta: e.metaJson ? safeParse(e.metaJson) : null,
+        createdAt: e.createdAt,
+      })),
+    });
+  } catch (err) {
+    next(err);
+  }
+});
+
+adminRouter.get('/admin/jobs', async (_req, res, next) => {
+  try {
+    const [stats, dead] = await Promise.all([
+      queueStats(),
+      db().job.findMany({
+        where: { status: 'dead' },
+        orderBy: { updatedAt: 'desc' },
+        take: 20,
+        select: { id: true, type: true, attempts: true, lastError: true, updatedAt: true },
+      }),
+    ]);
+    res.json({ ok: true, stats, dead });
+  } catch (err) {
+    next(err);
+  }
+});
+
+adminRouter.get('/admin/messages', async (req, res, next) => {
+  try {
+    const conversationId = req.query.conversationId ? Number(req.query.conversationId) : undefined;
+    const rows = await db().processedMessage.findMany({
+      where: conversationId && Number.isFinite(conversationId) ? { conversationId } : undefined,
+      orderBy: { createdAt: 'desc' },
+      take: 50,
+    });
+    res.json({ ok: true, count: rows.length, messages: rows });
+  } catch (err) {
+    next(err);
+  }
+});
+
+function safeParse(json: string): unknown {
+  try {
+    return JSON.parse(json);
+  } catch {
+    return null;
+  }
+}

+ 77 - 0
src/http/routes/health.ts

@@ -0,0 +1,77 @@
+import { Router } from 'express';
+import { config } from '../../config.js';
+import { ChatwootClient } from '../../clients/chatwootClient.js';
+import { FlowiseClient } from '../../clients/flowiseClient.js';
+import { WooClient } from '../../clients/wooClient.js';
+import { WpStoreClient } from '../../clients/wpStoreClient.js';
+import { queueStats } from '../../queue/jobQueue.js';
+import { db } from '../../store/db.js';
+
+export const healthRouter = Router();
+
+const startedAt = Date.now();
+
+/** Liveness: no upstreams, no DB — must stay fast and dependency-free. */
+healthRouter.get('/health', (_req, res) => {
+  res.json({
+    ok: true,
+    service: 'eks-relay',
+    version: process.env.npm_package_version ?? '2.0.0',
+    mode: config().RELAY_MODE,
+    uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
+    timestamp: new Date().toISOString(),
+  });
+});
+
+/**
+ * Readiness: probes every dependency and reports host+path and status code
+ * only. No tokens, no URLs with query strings, no secret presence details
+ * beyond a boolean "configured" flag.
+ */
+healthRouter.get('/ready', async (_req, res) => {
+  const cfg = config();
+
+  const [database, chatwoot, flowise, woo, wpStore] = await Promise.all([
+    probeDatabase(),
+    new ChatwootClient().ping(),
+    new FlowiseClient().ping(),
+    new WooClient().ping(),
+    new WpStoreClient().ping(),
+  ]);
+
+  let queue: Record<string, number> | { error: string };
+  try {
+    queue = await queueStats();
+  } catch {
+    queue = { error: 'unavailable' };
+  }
+
+  const dependencies = { database, chatwoot, flowise, woocommerce: woo, wpStore };
+  const ready = database.ok && chatwoot.ok;
+
+  res.status(ready ? 200 : 503).json({
+    ok: ready,
+    service: 'eks-relay',
+    mode: cfg.RELAY_MODE,
+    spamGate: cfg.SPAM_GATE_ENABLED,
+    worker: cfg.WORKER_ENABLED,
+    configured: {
+      flowiseApiKey: cfg.FLOWISE_API_KEY !== '',
+      adminToken: cfg.ADMIN_TOKEN !== '',
+      wpStoreSecret: cfg.WP_STORE_API_SECRET !== '',
+      chatwootApiInbox: cfg.CHATWOOT_API_INBOX_ID !== '',
+    },
+    dependencies,
+    queue,
+    timestamp: new Date().toISOString(),
+  });
+});
+
+async function probeDatabase(): Promise<{ ok: boolean; status: number; target: string }> {
+  try {
+    await db().$queryRaw`SELECT 1`;
+    return { ok: true, status: 200, target: 'sqlite' };
+  } catch {
+    return { ok: false, status: 0, target: 'sqlite' };
+  }
+}

+ 348 - 0
src/http/routes/tools.ts

@@ -0,0 +1,348 @@
+import { Router, type Request } from 'express';
+import type { ZodType } from 'zod';
+import { requireToolAuth } from '../middleware/auth.js';
+import { badRequest, RelayError } from '../../errors.js';
+import { WooClient } from '../../clients/wooClient.js';
+import { WpStoreClient } from '../../clients/wpStoreClient.js';
+import { ChatwootClient } from '../../clients/chatwootClient.js';
+import { createTicket } from '../../domain/ticketService.js';
+import { currencyMeta, resolveCurrency } from '../../domain/storeLocales.js';
+import { formatOrder, formatPaymentGateway, formatProduct } from '../../domain/formatters.js';
+import { audit } from '../../store/auditLog.js';
+import { logger } from '../../logger.js';
+import {
+  getCarDataSchema,
+  getOrderDataSchema,
+  getPaymentMethodsSchema,
+  getProductCompatibilitySchema,
+  getProductDataSchema,
+  getShippingDataSchema,
+  newTicketSchema,
+  pickInt,
+  pickString,
+} from '../../types/tools.js';
+
+export const toolsRouter = Router();
+
+toolsRouter.use('/tools', requireToolAuth);
+
+function parseBody<T>(schema: ZodType<T>, req: Request): T {
+  const result = schema.safeParse(req.body ?? {});
+  if (!result.success) {
+    const fields = result.error.issues.map((i) => i.path.join('.')).join(', ');
+    throw badRequest('INVALID_PARAMS', `Invalid or missing parameters: ${fields}`);
+  }
+  return result.data;
+}
+
+// ─────────────────────────────────────────────── POST /tools/get_order_data
+
+toolsRouter.post('/tools/get_order_data', async (req, res, next) => {
+  try {
+    const body = parseBody(getOrderDataSchema, req);
+    const email = pickString(body.email);
+    const orderNumber = pickString(body.orderNumber, body.order_number);
+    const language = pickString(body.language) || null;
+    const locale = resolveCurrency(language, pickString(body.currency));
+
+    if (!email) {
+      throw badRequest('MISSING_PARAMS', 'email is required to verify order ownership.');
+    }
+
+    const woo = new WooClient();
+
+    let order;
+    if (orderNumber) {
+      order = await woo.getOrder({ orderNumber, currency: locale.currency });
+      // Ownership check: never reveal an order to an address that did not place it.
+      const billing = (order.billing ?? {}) as Record<string, unknown>;
+      const billingEmail = String(billing.email ?? '').trim().toLowerCase();
+      if (!billingEmail || billingEmail !== email.trim().toLowerCase()) {
+        logger.info('Order e-mail ownership mismatch', { orderNumber });
+        throw new RelayError(
+          403,
+          'UNAUTHORIZED',
+          'Provided e-mail does not match the order.',
+        );
+      }
+    } else {
+      order = await woo.getOrder({ email, currency: locale.currency });
+    }
+
+    res.json({
+      ok: true,
+      data: formatOrder(order),
+      ...currencyMeta(locale, language),
+    });
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ───────────────────────────────────────────── POST /tools/get_product_data
+
+toolsRouter.post('/tools/get_product_data', async (req, res, next) => {
+  try {
+    const body = parseBody(getProductDataSchema, req);
+    const productId = pickInt(body.productId, body.product_id);
+    const sku = pickString(body.sku);
+    const search = pickString(body.search);
+    const language = pickString(body.language) || null;
+    const locale = resolveCurrency(language, pickString(body.currency));
+    const meta = currencyMeta(locale, language);
+    const lang = language ?? '';
+
+    const woo = new WooClient();
+
+    if (productId > 0) {
+      const product = await woo.getProduct({ productId, currency: locale.currency, lang });
+      res.json({ ok: true, data: formatProduct(product), ...meta });
+      return;
+    }
+    if (sku) {
+      const product = await woo.getProduct({ sku, currency: locale.currency, lang });
+      res.json({ ok: true, data: formatProduct(product), ...meta });
+      return;
+    }
+    if (search) {
+      const products = await woo.searchProducts(search, 5, locale.currency, lang);
+      res.json({ ok: true, data: products.map(formatProduct), ...meta });
+      return;
+    }
+
+    throw badRequest('MISSING_PARAMS', 'Provide productId, sku or search.');
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ──────────────────────────────────────────── POST /tools/get_shipping_data
+
+toolsRouter.post('/tools/get_shipping_data', async (req, res, next) => {
+  try {
+    const body = parseBody(getShippingDataSchema, req);
+    const language = pickString(body.language) || null;
+    const locale = resolveCurrency(language, pickString(body.currency));
+    const hasZone =
+      (body.zoneId ?? body.zone_id) !== undefined && (body.zoneId ?? body.zone_id) !== null;
+    const zoneId = pickInt(body.zoneId, body.zone_id);
+
+    // Per-zone request: the WP endpoint is the only source of multi-currency
+    // costs — WCML keeps them in wp_options, invisible to the Woo REST API.
+    if (hasZone) {
+      const data = await new WpStoreClient().shippingCosts(zoneId, locale.currency);
+      res.json({ ok: true, data, ...currencyMeta(locale, language) });
+      return;
+    }
+
+    const woo = new WooClient();
+    const zones = await woo.getShippingZones();
+    const formatted = await Promise.all(
+      zones.map(async (z) => {
+        const zid = Number(z.id ?? 0);
+        const locations = await woo.getShippingZoneLocations(zid);
+        const countries = locations
+          .filter((l) => l.type === 'country')
+          .map((l) => String(l.code ?? '').toUpperCase())
+          .filter(Boolean);
+        return { id: zid, name: String(z.name ?? ''), countries };
+      }),
+    );
+
+    res.json({
+      ok: true,
+      data: formatted,
+      hint: 'Pass {"zoneId": <id>} to get shipping methods for a specific zone.',
+    });
+  } catch (err) {
+    if (err instanceof RelayError && err.httpCode === 502) {
+      // The tool code expects `ok:false` in a 200 body for "feature unavailable",
+      // so it can explain the gap instead of surfacing a transport error.
+      res.status(200).json({
+        ok: false,
+        code: 'NOT_IMPLEMENTED',
+        message:
+          'Shipping data is not available. Verify shipping zones and that the consumer key can read shipping endpoints.',
+      });
+      return;
+    }
+    next(err);
+  }
+});
+
+// ───────────────────────────────────────── POST /tools/get_payment_methods
+
+toolsRouter.post('/tools/get_payment_methods', async (req, res, next) => {
+  try {
+    parseBody(getPaymentMethodsSchema, req);
+    const gateways = await new WooClient().getPaymentGateways();
+    const enabled = gateways.filter((g) => g.enabled === true).map(formatPaymentGateway);
+    res.json({ ok: true, data: enabled });
+  } catch (err) {
+    if (err instanceof RelayError && err.httpCode === 502) {
+      res.status(200).json({
+        ok: false,
+        code: 'NOT_IMPLEMENTED',
+        message:
+          'Payment gateways endpoint not accessible. The WooCommerce consumer key needs read/write permissions for GET /payment_gateways.',
+      });
+      return;
+    }
+    next(err);
+  }
+});
+
+// ─────────────────────────────────── POST /tools/get_product_compatibility
+
+toolsRouter.post('/tools/get_product_compatibility', async (req, res, next) => {
+  try {
+    const body = parseBody(getProductCompatibilitySchema, req);
+    const language = pickString(body.language) || null;
+    const locale = resolveCurrency(language, pickString(body.currency));
+
+    let productName = pickString(body.product_name, body.productName);
+    let productId = pickInt(body.productId, body.product_id);
+    const carBrand = pickString(body.car_brand, body.carBrand);
+    const carModel = pickString(body.car_model, body.carModel);
+    const carYear = pickString(body.car_year, body.carYear);
+    const carEngine = pickString(body.car_engine, body.carEngine);
+    const carEngineIndex =
+      body.car_engine_index === undefined || body.car_engine_index === null
+        ? -1
+        : pickInt(body.car_engine_index);
+
+    const woo = new WooClient();
+
+    // Pre-resolve a non-Polish product name to an ID: the WP endpoint searches
+    // the Polish post table only, so a German/English phrase would miss.
+    if (productName && productId <= 0) {
+      let found = await woo.searchProducts(productName, 5, '', language ?? '');
+      if (found.length === 0 && language && language !== 'pl') {
+        found = await woo.searchProducts(productName, 5, '');
+      }
+      if (found.length === 1 && found[0]?.id) {
+        productId = Number(found[0].id);
+        productName = '';
+      }
+    }
+
+    const payload: Record<string, unknown> = {};
+    if (productId > 0) payload.product_id = productId;
+    if (productName) payload.product_name = productName;
+    if (carBrand) payload.car_brand = carBrand;
+    if (carModel) payload.car_model = carModel;
+    if (carYear) payload.car_year = carYear;
+    if (carEngine) payload.car_engine = carEngine;
+    if (carEngineIndex >= 0) payload.car_engine_index = carEngineIndex;
+    if (language) payload.lang = language;
+    payload.currency = locale.currency;
+
+    const raw = await new WpStoreClient().productCompatibility(payload);
+    const data = await translateCompatibilityTitles(raw, language, locale.currency, woo);
+
+    res.json({ ok: true, data, ...currencyMeta(locale, language) });
+  } catch (err) {
+    next(err);
+  }
+});
+
+/**
+ * The WP endpoint does not switch WPML for product titles, so anything shown to
+ * a non-Polish customer is re-fetched from the Woo REST API in their language.
+ */
+async function translateCompatibilityTitles(
+  raw: unknown,
+  language: string | null,
+  currency: string,
+  woo: WooClient,
+): Promise<unknown> {
+  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
+  if (!language || language === 'pl') return raw;
+
+  const data = raw as Record<string, unknown>;
+
+  const selected = data.selected_product as Record<string, unknown> | undefined;
+  if (selected?.id) {
+    try {
+      const translated = await woo.getProduct({
+        productId: Number(selected.id),
+        lang: language,
+        currency,
+      });
+      if (translated.name) selected.title = translated.name;
+    } catch {
+      // Keep the original title when the translation lookup fails.
+    }
+  }
+
+  if (data.error === 'options' && data.field === 'product_name' && Array.isArray(data.options)) {
+    const options = data.options as Record<string, unknown>[];
+    const ids = options.map((o) => Number(o.id)).filter((n) => Number.isFinite(n) && n > 0);
+    if (ids.length > 0) {
+      try {
+        const translated = await woo.getProductsByIds(ids, language, currency);
+        const byId = new Map(translated.map((p) => [Number(p.id), String(p.name ?? '')]));
+        for (const opt of options) {
+          const name = byId.get(Number(opt.id));
+          if (name) opt.title = name;
+        }
+      } catch {
+        // Keep the original titles.
+      }
+    }
+  }
+
+  return data;
+}
+
+// ───────────────────────────────────────────────── POST /tools/get_car_data
+
+toolsRouter.post('/tools/get_car_data', async (req, res, next) => {
+  try {
+    const body = parseBody(getCarDataSchema, req);
+    const payload: Record<string, unknown> = {};
+    const brand = pickString(body.car_brand, body.make);
+    const model = pickString(body.car_model, body.model);
+    const year = pickString(body.car_year, body.year);
+    const engine = pickString(body.car_engine, body.engine);
+
+    if (brand) payload.car_brand = brand;
+    if (model) payload.car_model = model;
+    if (year) payload.car_year = year;
+    if (engine) payload.car_engine = engine;
+
+    const data = await new WpStoreClient().carData(payload);
+    res.json({ ok: true, data });
+  } catch (err) {
+    next(err);
+  }
+});
+
+// ─────────────────────────────────────────────────── POST /tools/new_ticket
+
+toolsRouter.post('/tools/new_ticket', async (req, res, next) => {
+  try {
+    const body = parseBody(newTicketSchema, req);
+    const conversationId = pickInt(body.conversationId, body.conversation_id);
+    if (conversationId <= 0) {
+      throw badRequest('MISSING_PARAMS', 'conversationId is required.');
+    }
+
+    const summary = pickString(body.summary, body.reason);
+    const result = await createTicket(
+      conversationId,
+      summary ? summary.slice(0, 200) : 'flowise_tool_new_ticket',
+      new ChatwootClient(),
+    );
+
+    await audit({
+      conversationId,
+      eventType: 'tool_new_ticket',
+      summary: `new_ticket -> ${result.ticketNumber} (${result.status})`,
+    });
+
+    res.json(result);
+  } catch (err) {
+    next(err);
+  }
+});

+ 90 - 0
src/http/routes/webhooks.ts

@@ -0,0 +1,90 @@
+import { Router } from 'express';
+import { chatwootWebhookSchema } from '../../types/chatwoot.js';
+import { normalizeChatwootWebhook } from '../../domain/messageNormalizer.js';
+import { claimMessage } from '../../store/idempotencyStore.js';
+import { enqueue } from '../../queue/jobQueue.js';
+import { audit } from '../../store/auditLog.js';
+import { logger } from '../../logger.js';
+import { config } from '../../config.js';
+import { drainOnce } from '../../queue/worker.js';
+
+export const webhookRouter = Router();
+
+/**
+ * Chatwoot `message_created` entry point.
+ *
+ * Contract: acknowledge fast (202) and never let a slow Flowise call cause a
+ * Chatwoot-side timeout and retry. Anything that is not an actionable incoming
+ * customer message is answered 200 with an explicit skip reason, so Chatwoot
+ * does not keep retrying it.
+ */
+webhookRouter.post('/webhooks/chatwoot', async (req, res, next) => {
+  try {
+    const parsed = chatwootWebhookSchema.safeParse(req.body);
+    if (!parsed.success) {
+      logger.warn('Malformed Chatwoot webhook payload', {
+        issues: parsed.error.issues.map((i) => i.path.join('.')),
+      });
+      res.status(200).json({ ok: true, skipped: true, reason: 'invalid_payload' });
+      return;
+    }
+
+    const normalized = normalizeChatwootWebhook(parsed.data);
+    if (!normalized.ok) {
+      logger.info('Webhook ignored', { reason: normalized.reason });
+      res.status(200).json({ ok: true, skipped: true, reason: normalized.reason });
+      return;
+    }
+
+    const event = normalized.event;
+
+    // Idempotency: the unique (source, messageId) insert decides the winner of
+    // a retry race before any job is created.
+    const claim = await claimMessage(event.source, event.messageId, event.conversationId);
+    if (!claim.claimed) {
+      logger.info('Duplicate message ignored', {
+        conversationId: event.conversationId,
+        messageId: event.messageId,
+        previousStatus: claim.status,
+      });
+      res.status(200).json({
+        ok: true,
+        duplicate: true,
+        status: claim.status,
+        conversationId: event.conversationId,
+      });
+      return;
+    }
+
+    const jobId = await enqueue({ type: 'chatwoot_message', payload: { ...event } });
+
+    await audit({
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+      eventType: 'webhook_accepted',
+      summary: `Queued job ${jobId} for conversation ${event.conversationId}`,
+      meta: { channel: event.channel, inboxId: event.inboxId, attachments: event.attachmentCount },
+    });
+
+    res.status(202).json({
+      ok: true,
+      accepted: true,
+      jobId,
+      conversationId: event.conversationId,
+      messageId: event.messageId,
+    });
+
+    // With the background worker disabled (one-shot runs) still make progress
+    // once the response has been flushed. Tests drive the queue explicitly.
+    const cfg = config();
+    if (!cfg.WORKER_ENABLED && cfg.NODE_ENV !== 'test') {
+      void drainOnce().catch((err: unknown) => {
+        logger.error('Inline drain failed', {
+          error: err instanceof Error ? err.message : String(err),
+        });
+      });
+    }
+  } catch (err) {
+    next(err);
+  }
+});

+ 49 - 0
src/index.ts

@@ -0,0 +1,49 @@
+import { config } from './config.js';
+import { configureLogger, logger } from './logger.js';
+import { createApp } from './http/app.js';
+import { startWorker, stopWorker } from './queue/worker.js';
+import { disconnectDb } from './store/db.js';
+
+async function main(): Promise<void> {
+  const cfg = config();
+  configureLogger({ level: cfg.LOG_LEVEL, logPii: cfg.LOG_PII });
+
+  const app = createApp();
+  const server = app.listen(cfg.PORT, '0.0.0.0', () => {
+    logger.info('EKS Relay listening', {
+      port: cfg.PORT,
+      mode: cfg.RELAY_MODE,
+      nodeEnv: cfg.NODE_ENV,
+      worker: cfg.WORKER_ENABLED,
+      spamGate: cfg.SPAM_GATE_ENABLED,
+    });
+  });
+
+  if (cfg.WORKER_ENABLED) startWorker();
+
+  const shutdown = (signal: string): void => {
+    logger.info('Shutting down', { signal });
+    stopWorker();
+    server.close(() => {
+      void disconnectDb().finally(() => process.exit(0));
+    });
+    // Never hang a container restart on a stuck connection.
+    setTimeout(() => process.exit(0), 10_000).unref();
+  };
+
+  process.on('SIGTERM', () => shutdown('SIGTERM'));
+  process.on('SIGINT', () => shutdown('SIGINT'));
+}
+
+main().catch((err: unknown) => {
+  // Config errors surface here; they name the variable, never its value.
+  process.stderr.write(
+    `${JSON.stringify({
+      ts: new Date().toISOString(),
+      level: 'error',
+      msg: 'Fatal startup error',
+      error: err instanceof Error ? err.message : String(err),
+    })}\n`,
+  );
+  process.exit(1);
+});

+ 108 - 0
src/logger.ts

@@ -0,0 +1,108 @@
+/**
+ * Structured JSON logger with a hard redaction pass.
+ *
+ * Two separate concerns:
+ *  - secrets (tokens, keys, Authorization headers) are ALWAYS redacted;
+ *  - PII (customer e-mail, name, message bodies) is redacted unless LOG_PII=true,
+ *    which should only ever be enabled temporarily while debugging.
+ */
+
+export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
+
+const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
+
+const SECRET_KEY_PATTERN =
+  /(token|secret|password|passwd|api[_-]?key|authorization|consumer_key|consumer_secret|cookie|credential|private[_-]?key)/i;
+
+const PII_KEY_PATTERN = /(email|phone|content|question|answer|text|address|first_name|last_name)/i;
+
+const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
+const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._\-+/=]+/gi;
+const WOO_KEY_PATTERN = /\b(ck|cs)_[A-Za-z0-9]{8,}\b/g;
+const QUERY_SECRET_PATTERN = /([?&](?:consumer_key|consumer_secret|token|api_key)=)[^&\s]+/gi;
+
+export interface LoggerOptions {
+  level: LogLevel;
+  logPii: boolean;
+}
+
+let options: LoggerOptions = { level: 'info', logPii: false };
+
+export function configureLogger(next: Partial<LoggerOptions>): void {
+  options = { ...options, ...next };
+}
+
+/** Redact secret-looking substrings inside a free-form string. */
+export function redactString(input: string, logPii = options.logPii): string {
+  let out = input
+    .replace(BEARER_PATTERN, 'Bearer [REDACTED]')
+    .replace(WOO_KEY_PATTERN, '[REDACTED_WOO_KEY]')
+    .replace(QUERY_SECRET_PATTERN, '$1[REDACTED]');
+  if (!logPii) {
+    out = out.replace(EMAIL_PATTERN, '[REDACTED_EMAIL]');
+  }
+  return out;
+}
+
+/** Mask an e-mail for audit records: `jan.kowalski@example.com` -> `j***@example.com`. */
+export function maskEmail(email: string): string {
+  const at = email.indexOf('@');
+  if (at <= 0) return '[REDACTED]';
+  const local = email.slice(0, at);
+  const domain = email.slice(at + 1);
+  return `${local[0]}***@${domain}`;
+}
+
+/**
+ * Deep-redact an arbitrary value for logging. Secret keys become `[REDACTED]`
+ * unconditionally; PII keys are masked unless PII logging is switched on.
+ */
+export function redact(value: unknown, logPii = options.logPii, depth = 0): unknown {
+  if (depth > 8) return '[TRUNCATED_DEPTH]';
+  if (value === null || value === undefined) return value;
+  if (typeof value === 'string') {
+    const s = redactString(value, logPii);
+    return s.length > 2000 ? `${s.slice(0, 2000)}…[TRUNCATED]` : s;
+  }
+  if (typeof value === 'number' || typeof value === 'boolean') return value;
+  if (Array.isArray(value)) {
+    return value.slice(0, 50).map((v) => redact(v, logPii, depth + 1));
+  }
+  if (value instanceof Error) {
+    return { name: value.name, message: redactString(value.message, logPii) };
+  }
+  if (typeof value === 'object') {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      if (SECRET_KEY_PATTERN.test(k)) {
+        out[k] = '[REDACTED]';
+      } else if (!logPii && PII_KEY_PATTERN.test(k)) {
+        out[k] = typeof v === 'string' && v.includes('@') ? maskEmail(v) : '[REDACTED_PII]';
+      } else {
+        out[k] = redact(v, logPii, depth + 1);
+      }
+    }
+    return out;
+  }
+  return '[UNSERIALIZABLE]';
+}
+
+function emit(level: LogLevel, msg: string, ctx?: Record<string, unknown>): void {
+  if (LEVELS[level] < LEVELS[options.level]) return;
+  const line = {
+    ts: new Date().toISOString(),
+    level,
+    msg: redactString(msg),
+    ...(ctx ? { ctx: redact(ctx) } : {}),
+  };
+  const serialized = JSON.stringify(line);
+  if (level === 'error' || level === 'warn') process.stderr.write(`${serialized}\n`);
+  else process.stdout.write(`${serialized}\n`);
+}
+
+export const logger = {
+  debug: (msg: string, ctx?: Record<string, unknown>) => emit('debug', msg, ctx),
+  info: (msg: string, ctx?: Record<string, unknown>) => emit('info', msg, ctx),
+  warn: (msg: string, ctx?: Record<string, unknown>) => emit('warn', msg, ctx),
+  error: (msg: string, ctx?: Record<string, unknown>) => emit('error', msg, ctx),
+};

+ 88 - 0
src/queue/jobQueue.ts

@@ -0,0 +1,88 @@
+import { db } from '../store/db.js';
+import { config } from '../config.js';
+import { redact } from '../logger.js';
+
+export type JobType = 'chatwoot_message';
+
+export interface EnqueueInput {
+  type: JobType;
+  payload: Record<string, unknown>;
+  maxAttempts?: number;
+}
+
+/**
+ * Persist a unit of work. The webhook returns 202 as soon as this lands, so a
+ * slow Flowise call can never make Chatwoot time out and retry.
+ */
+export async function enqueue(input: EnqueueInput): Promise<string> {
+  const job = await db().job.create({
+    data: {
+      type: input.type,
+      status: 'queued',
+      payloadJson: JSON.stringify(redact(input.payload, true)),
+      maxAttempts: input.maxAttempts ?? config().WORKER_MAX_ATTEMPTS,
+    },
+  });
+  return job.id;
+}
+
+/** Claim the next runnable job, guarding against double-claim via updateMany. */
+export async function claimNextJob() {
+  const candidate = await db().job.findFirst({
+    where: { status: 'queued', runAfter: { lte: new Date() } },
+    orderBy: { createdAt: 'asc' },
+  });
+  if (!candidate) return null;
+
+  const claimed = await db().job.updateMany({
+    where: { id: candidate.id, status: 'queued' },
+    data: { status: 'processing', startedAt: new Date(), attempts: { increment: 1 } },
+  });
+  if (claimed.count === 0) return null;
+
+  return db().job.findUnique({ where: { id: candidate.id } });
+}
+
+export async function completeJob(id: string): Promise<void> {
+  await db().job.update({
+    where: { id },
+    data: { status: 'done', lastError: null, finishedAt: new Date() },
+  });
+}
+
+/**
+ * Record a failure. Below maxAttempts the job goes back to `queued` with
+ * exponential backoff; beyond it the job is parked as `dead` for inspection.
+ */
+export async function failJob(id: string, error: string): Promise<'retry' | 'dead'> {
+  const job = await db().job.findUnique({ where: { id } });
+  if (!job) return 'dead';
+
+  const message = String(redact(error, false)).slice(0, 1000);
+
+  if (job.attempts >= job.maxAttempts) {
+    await db().job.update({
+      where: { id },
+      data: { status: 'dead', lastError: message, finishedAt: new Date() },
+    });
+    return 'dead';
+  }
+
+  const backoffMs = Math.min(2 ** job.attempts * 5_000, 5 * 60_000);
+  await db().job.update({
+    where: { id },
+    data: {
+      status: 'queued',
+      lastError: message,
+      runAfter: new Date(Date.now() + backoffMs),
+    },
+  });
+  return 'retry';
+}
+
+export async function queueStats(): Promise<Record<string, number>> {
+  const rows = await db().job.groupBy({ by: ['status'], _count: { _all: true } });
+  const out: Record<string, number> = { queued: 0, processing: 0, done: 0, dead: 0 };
+  for (const r of rows) out[r.status] = r._count._all;
+  return out;
+}

+ 90 - 0
src/queue/worker.ts

@@ -0,0 +1,90 @@
+import { config } from '../config.js';
+import { logger } from '../logger.js';
+import { claimNextJob, completeJob, failJob } from './jobQueue.js';
+import { processMessageEvent } from '../domain/conversationPipeline.js';
+import { setMessageStatus } from '../store/idempotencyStore.js';
+import { audit } from '../store/auditLog.js';
+import type { SupportMessageEvent } from '../domain/messageNormalizer.js';
+
+let running = false;
+let timer: NodeJS.Timeout | null = null;
+
+/**
+ * Single in-process worker. One job at a time is deliberate: the relay talks to
+ * an LLM and to a live customer inbox, so ordered, low-concurrency processing is
+ * worth more than throughput here.
+ */
+export function startWorker(): void {
+  if (running) return;
+  running = true;
+  const pollMs = config().WORKER_POLL_MS;
+
+  const tick = async (): Promise<void> => {
+    if (!running) return;
+    try {
+      await drainOnce();
+    } catch (err) {
+      logger.error('Worker tick failed', { err: err instanceof Error ? err.message : String(err) });
+    } finally {
+      if (running) timer = setTimeout(() => void tick(), pollMs);
+    }
+  };
+
+  logger.info('Job worker started', { pollMs });
+  timer = setTimeout(() => void tick(), pollMs);
+}
+
+export function stopWorker(): void {
+  running = false;
+  if (timer) clearTimeout(timer);
+  timer = null;
+}
+
+/** Process every currently runnable job; exported so tests can drive it. */
+export async function drainOnce(limit = 10): Promise<number> {
+  let processed = 0;
+  for (let i = 0; i < limit; i++) {
+    const job = await claimNextJob();
+    if (!job) break;
+    await runJob(job.id, job.type, job.payloadJson);
+    processed++;
+  }
+  return processed;
+}
+
+async function runJob(id: string, type: string, payloadJson: string): Promise<void> {
+  try {
+    if (type !== 'chatwoot_message') {
+      throw new Error(`Unknown job type: ${type}`);
+    }
+    const event = JSON.parse(payloadJson) as SupportMessageEvent;
+    await setMessageStatus(event.source, event.messageId, 'processing');
+    const outcome = await processMessageEvent(event);
+    await completeJob(id);
+    logger.info('Job completed', {
+      jobId: id,
+      conversationId: event.conversationId,
+      action: outcome.action,
+    });
+  } catch (err) {
+    const message = err instanceof Error ? err.message : String(err);
+    const disposition = await failJob(id, message);
+    logger.error('Job failed', { jobId: id, disposition, error: message });
+
+    if (disposition === 'dead') {
+      try {
+        const event = JSON.parse(payloadJson) as SupportMessageEvent;
+        await setMessageStatus(event.source, event.messageId, 'failed', 'job_dead');
+        await audit({
+          conversationId: event.conversationId,
+          messageId: event.messageId,
+          eventType: 'job_dead',
+          summary: 'Job exhausted all retries',
+          meta: { error: message },
+        });
+      } catch {
+        // Payload was unparseable — nothing more to record.
+      }
+    }
+  }
+}

+ 34 - 0
src/store/auditLog.ts

@@ -0,0 +1,34 @@
+import { db } from './db.js';
+import { redact } from '../logger.js';
+
+export interface AuditInput {
+  conversationId?: number | null;
+  messageId?: string | null;
+  eventType: string;
+  summary: string;
+  meta?: Record<string, unknown>;
+}
+
+/**
+ * Append a non-secret audit row. Metadata always goes through the redactor, so
+ * /admin/events can never become a secret- or PII-leak channel.
+ */
+export async function audit(input: AuditInput): Promise<void> {
+  await db().auditEvent.create({
+    data: {
+      conversationId: input.conversationId ?? null,
+      messageId: input.messageId ?? null,
+      eventType: input.eventType,
+      summary: input.summary.slice(0, 500),
+      metaJson: input.meta ? JSON.stringify(redact(input.meta, false)) : null,
+    },
+  });
+}
+
+export async function recentEvents(limit = 50, conversationId?: number) {
+  return db().auditEvent.findMany({
+    where: conversationId ? { conversationId } : undefined,
+    orderBy: { createdAt: 'desc' },
+    take: Math.min(Math.max(limit, 1), 200),
+  });
+}

+ 15 - 0
src/store/db.ts

@@ -0,0 +1,15 @@
+import { PrismaClient } from '@prisma/client';
+
+let client: PrismaClient | null = null;
+
+export function db(): PrismaClient {
+  client ??= new PrismaClient();
+  return client;
+}
+
+export async function disconnectDb(): Promise<void> {
+  if (client) {
+    await client.$disconnect();
+    client = null;
+  }
+}

+ 66 - 0
src/store/idempotencyStore.ts

@@ -0,0 +1,66 @@
+import { db } from './db.js';
+
+export type MessageStatus =
+  | 'queued'
+  | 'processing'
+  | 'replied'
+  | 'skipped'
+  | 'ticket'
+  | 'failed'
+  | 'spam';
+
+export interface ClaimResult {
+  /** false when this (source, messageId) was already seen. */
+  claimed: boolean;
+  status: MessageStatus;
+}
+
+/**
+ * Atomically record a message as seen. The unique (source, messageId) index is
+ * the real guard: concurrent webhook retries race on the insert and exactly one
+ * of them wins.
+ */
+export async function claimMessage(
+  source: string,
+  messageId: string,
+  conversationId: number,
+): Promise<ClaimResult> {
+  try {
+    const row = await db().processedMessage.create({
+      data: { source, messageId, conversationId, status: 'queued' },
+    });
+    return { claimed: true, status: row.status as MessageStatus };
+  } catch (err) {
+    if (isUniqueViolation(err)) {
+      const existing = await db().processedMessage.findUnique({
+        where: { source_messageId: { source, messageId } },
+      });
+      return { claimed: false, status: (existing?.status ?? 'queued') as MessageStatus };
+    }
+    throw err;
+  }
+}
+
+export async function setMessageStatus(
+  source: string,
+  messageId: string,
+  status: MessageStatus,
+  reason?: string,
+): Promise<void> {
+  await db().processedMessage.updateMany({
+    where: { source, messageId },
+    data: { status, reason: reason ?? null },
+  });
+}
+
+export async function getMessage(source: string, messageId: string) {
+  return db().processedMessage.findUnique({ where: { source_messageId: { source, messageId } } });
+}
+
+function isUniqueViolation(err: unknown): boolean {
+  return (
+    typeof err === 'object' &&
+    err !== null &&
+    (err as { code?: string }).code === 'P2002'
+  );
+}

+ 51 - 0
src/types/chatwoot.ts

@@ -0,0 +1,51 @@
+import { z } from 'zod';
+
+/**
+ * Chatwoot webhook payloads vary by version and channel, so the schema is
+ * deliberately permissive: it pins the fields the relay routes on and lets
+ * everything else through untouched.
+ */
+export const chatwootSenderSchema = z
+  .object({
+    id: z.union([z.number(), z.string()]).optional(),
+    name: z.string().optional(),
+    email: z.string().optional(),
+    type: z.string().optional(),
+  })
+  .passthrough();
+
+export const chatwootConversationSchema = z
+  .object({
+    id: z.number().optional(),
+    inbox_id: z.number().optional(),
+    status: z.string().optional(),
+    channel: z.string().optional(),
+    labels: z.array(z.string()).optional(),
+    custom_attributes: z.record(z.string(), z.unknown()).optional(),
+    additional_attributes: z.record(z.string(), z.unknown()).optional(),
+    meta: z
+      .object({ sender: chatwootSenderSchema.optional() })
+      .passthrough()
+      .optional(),
+    contact_inbox: z.object({ source_id: z.string().optional() }).passthrough().optional(),
+  })
+  .passthrough();
+
+export const chatwootWebhookSchema = z
+  .object({
+    event: z.string().optional(),
+    id: z.union([z.number(), z.string()]).optional(),
+    message_type: z.string().optional(),
+    content_type: z.string().optional(),
+    content: z.string().nullable().optional(),
+    private: z.boolean().optional(),
+    source_id: z.string().nullable().optional(),
+    conversation: chatwootConversationSchema.optional(),
+    sender: chatwootSenderSchema.optional(),
+    inbox: z.object({ id: z.number().optional(), name: z.string().optional() }).passthrough().optional(),
+    attachments: z.array(z.record(z.string(), z.unknown())).optional(),
+  })
+  .passthrough();
+
+export type ChatwootWebhookPayload = z.infer<typeof chatwootWebhookSchema>;
+export type ChatwootSender = z.infer<typeof chatwootSenderSchema>;

+ 91 - 0
src/types/tools.ts

@@ -0,0 +1,91 @@
+import { z } from 'zod';
+
+const optionalString = z.union([z.string(), z.number()]).optional().nullable();
+const optionalInt = z.union([z.number(), z.string()]).optional().nullable();
+
+export const getOrderDataSchema = z.object({
+  email: z.string().min(3),
+  orderNumber: optionalString,
+  order_number: optionalString,
+  language: optionalString,
+  currency: optionalString,
+});
+
+export const getProductDataSchema = z.object({
+  productId: optionalInt,
+  product_id: optionalInt,
+  sku: optionalString,
+  search: optionalString,
+  language: optionalString,
+  currency: optionalString,
+});
+
+export const getShippingDataSchema = z.object({
+  zoneId: optionalInt,
+  zone_id: optionalInt,
+  country: optionalString,
+  language: optionalString,
+  currency: optionalString,
+});
+
+export const getPaymentMethodsSchema = z.object({
+  language: optionalString,
+  country: optionalString,
+  currency: optionalString,
+});
+
+export const getProductCompatibilitySchema = z.object({
+  product_name: optionalString,
+  productName: optionalString,
+  product_id: optionalInt,
+  productId: optionalInt,
+  car_brand: optionalString,
+  carBrand: optionalString,
+  car_model: optionalString,
+  carModel: optionalString,
+  car_year: optionalString,
+  carYear: optionalString,
+  car_engine: optionalString,
+  carEngine: optionalString,
+  car_engine_index: optionalInt,
+  language: optionalString,
+  currency: optionalString,
+});
+
+export const getCarDataSchema = z.object({
+  car_brand: optionalString,
+  make: optionalString,
+  car_model: optionalString,
+  model: optionalString,
+  car_year: optionalString,
+  year: optionalString,
+  car_engine: optionalString,
+  engine: optionalString,
+});
+
+export const newTicketSchema = z.object({
+  conversationId: optionalInt,
+  conversation_id: optionalInt,
+  summary: optionalString,
+  reason: optionalString,
+  priority: optionalString,
+});
+
+/** Normalises the snake_case/camelCase pairs the Flowise tools send. */
+export function pickString(...values: unknown[]): string {
+  for (const v of values) {
+    if (v === undefined || v === null) continue;
+    const s = String(v).trim();
+    if (s !== '') return s;
+  }
+  return '';
+}
+
+export function pickInt(...values: unknown[]): number {
+  for (const v of values) {
+    if (v === undefined || v === null || v === '') continue;
+    const n = Number(v);
+    if (Number.isFinite(n)) return Math.trunc(n);
+  }
+  return 0;
+}

+ 31 - 0
tests/config.test.ts

@@ -0,0 +1,31 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { loadConfig } from '../src/config.js';
+import { applyTestConfig } from './helpers/testConfig.js';
+
+test('loadConfig rejects an incomplete environment', () => {
+  assert.throws(() => loadConfig({ NODE_ENV: 'test' }), /Invalid environment configuration/);
+});
+
+test('loadConfig never echoes a value in its error message', () => {
+  try {
+    loadConfig({ CHATWOOT_BASE_URL: 'not-a-url', CHATWOOT_API_TOKEN: 'super-secret-value' });
+    assert.fail('expected a throw');
+  } catch (err) {
+    assert.ok(err instanceof Error);
+    assert.ok(!err.message.includes('super-secret-value'));
+  }
+});
+
+test('boolean and csv env values are coerced', () => {
+  const cfg = applyTestConfig({ WORKER_ENABLED: 'yes', STORE_CURRENCIES: 'pln, eur ,czk' });
+  assert.equal(cfg.WORKER_ENABLED, true);
+  assert.deepEqual(cfg.STORE_CURRENCIES, ['PLN', 'EUR', 'CZK']);
+});
+
+test('defaults fill in the optional settings', () => {
+  const cfg = applyTestConfig();
+  assert.equal(cfg.CHATWOOT_TICKET_LABEL, 'ticket');
+  assert.equal(cfg.TICKET_NUMBER_PREFIX, 'EKS');
+  assert.equal(cfg.SPAM_GATE_ENABLED, true);
+});

+ 92 - 0
tests/fixtures/chatwoot.ts

@@ -0,0 +1,92 @@
+/** Realistic Chatwoot webhook payloads used across the test suite. */
+
+export const incomingEmailMessage = {
+  event: 'message_created',
+  id: 90210,
+  message_type: 'incoming',
+  content_type: 'incoming_email',
+  content: 'Dzień dobry, czy gaz R1234yf pasuje do mojego Golfa VII z 2016 roku?',
+  private: false,
+  conversation: {
+    id: 1311,
+    inbox_id: 1,
+    status: 'open',
+    channel: 'Channel::Email',
+    labels: [],
+    custom_attributes: {},
+    additional_attributes: {
+      source: 'email',
+      mail_subject: 'Pytanie o klimatyzację',
+    },
+    meta: {
+      sender: { id: 332, name: 'Jan Kowalski', email: 'jan.kowalski@example.com' },
+    },
+    contact_inbox: { source_id: 'jan.kowalski@example.com' },
+  },
+  sender: { id: 332, name: 'Jan Kowalski', email: 'jan.kowalski@example.com' },
+  attachments: [],
+};
+
+export const outgoingMessage = {
+  ...incomingEmailMessage,
+  id: 90211,
+  message_type: 'outgoing',
+};
+
+export const privateNote = {
+  ...incomingEmailMessage,
+  id: 90212,
+  private: true,
+};
+
+export const statusUpdate = {
+  event: 'conversation_status_changed',
+  id: 90213,
+  conversation: { id: 1311 },
+};
+
+export const missingConversationId = {
+  event: 'message_created',
+  id: 90214,
+  message_type: 'incoming',
+  content: 'hello',
+  conversation: {},
+};
+
+export const ticketedConversationMessage = {
+  ...incomingEmailMessage,
+  id: 90215,
+  conversation: {
+    ...incomingEmailMessage.conversation,
+    labels: ['ticket'],
+    custom_attributes: { ticket_number: 'EKS-20260820-1311', handoff: true },
+  },
+};
+
+export const bounceMessage = {
+  ...incomingEmailMessage,
+  id: 90216,
+  content: 'This is the mail system at host mx.example.com. Your message could not be delivered.',
+  conversation: {
+    ...incomingEmailMessage.conversation,
+    additional_attributes: {
+      source: 'email',
+      mail_subject: 'Undelivered Mail Returned to Sender',
+    },
+    meta: { sender: { id: 9, name: '', email: 'mailer-daemon@example.com' } },
+  },
+  sender: { id: 9, name: '', email: 'mailer-daemon@example.com' },
+};
+
+export const noLabelsPayload = {
+  event: 'message_created',
+  id: 90217,
+  message_type: 'incoming',
+  content: 'Gdzie jest moja paczka?',
+  conversation: {
+    id: 1412,
+    inbox_id: 1,
+    channel: 'Channel::Email',
+  },
+  sender: { id: 401, name: 'Anna Nowak', email: 'anna.nowak@example.com' },
+};

+ 41 - 0
tests/flowiseClient.test.ts

@@ -0,0 +1,41 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { normaliseFlowiseResponse } from '../src/clients/flowiseClient.js';
+
+test('reads the `text` field', () => {
+  const r = normaliseFlowiseResponse({ text: 'Cześć!' }, '{"text":"Cześć!"}');
+  assert.equal(r.type, 'reply');
+  assert.equal(r.text, 'Cześć!');
+});
+
+test('falls back to `response` and `answer`', () => {
+  assert.equal(normaliseFlowiseResponse({ response: 'A' }, '').text, 'A');
+  assert.equal(normaliseFlowiseResponse({ answer: 'B' }, '').text, 'B');
+});
+
+test('a handoff action wins over the plain reply path', () => {
+  const r = normaliseFlowiseResponse(
+    { text: 'Przekazuję do supportu', actions: [{ type: 'handoff' }] },
+    '',
+  );
+  assert.equal(r.type, 'handoff');
+  assert.equal(r.text, 'Przekazuję do supportu');
+});
+
+test('non-handoff actions stay a normal reply', () => {
+  const r = normaliseFlowiseResponse({ text: 'ok', actions: [{ type: 'log' }] }, '');
+  assert.equal(r.type, 'reply');
+  assert.equal(r.actions?.length, 1);
+});
+
+test('a plain-text body is the answer', () => {
+  const r = normaliseFlowiseResponse(null, 'Dzień dobry, oto odpowiedź.');
+  assert.equal(r.type, 'reply');
+  assert.equal(r.text, 'Dzień dobry, oto odpowiedź.');
+});
+
+test('an empty response is reported as unknown', () => {
+  assert.equal(normaliseFlowiseResponse(null, '').type, 'unknown');
+  assert.equal(normaliseFlowiseResponse({}, '{}').type, 'unknown');
+  assert.equal(normaliseFlowiseResponse({ text: '   ' }, '').type, 'unknown');
+});

+ 26 - 0
tests/helpers/testConfig.ts

@@ -0,0 +1,26 @@
+import { loadConfig, setConfigForTests, type Config } from '../../src/config.js';
+import { configureLogger } from '../../src/logger.js';
+
+const BASE_ENV: NodeJS.ProcessEnv = {
+  NODE_ENV: 'test',
+  DATABASE_URL: 'file:../data/test.db',
+  CHATWOOT_BASE_URL: 'https://chatwoot.test',
+  CHATWOOT_API_TOKEN: 'test-chatwoot-token',
+  CHATWOOT_ACCOUNT_ID: '1',
+  FLOWISE_PREDICT_URL: 'https://flowise.test/api/v1/prediction/abc',
+  FLOWISE_API_KEY: 'test-flowise-key',
+  WOOCOMMERCE_BASE_URL: 'https://shop.test',
+  WOOCOMMERCE_CONSUMER_KEY: 'ck_testtesttesttest',
+  WOOCOMMERCE_CONSUMER_SECRET: 'cs_testtesttesttest',
+  RELAY_SHARED_SECRET: 'test-shared-secret',
+  ADMIN_TOKEN: 'test-admin-token',
+  WORKER_ENABLED: 'false',
+  LOG_LEVEL: 'error',
+};
+
+export function applyTestConfig(overrides: NodeJS.ProcessEnv = {}): Config {
+  const cfg = loadConfig({ ...BASE_ENV, ...overrides });
+  setConfigForTests(cfg);
+  configureLogger({ level: cfg.LOG_LEVEL, logPii: cfg.LOG_PII });
+  return cfg;
+}

+ 49 - 0
tests/helpers/testServer.ts

@@ -0,0 +1,49 @@
+import { execFileSync } from 'node:child_process';
+import { mkdirSync, rmSync } from 'node:fs';
+import path from 'node:path';
+import type { AddressInfo } from 'node:net';
+import { createServer, type RequestListener } from 'node:http';
+
+const ROOT = path.resolve(import.meta.dirname, '../..');
+
+/**
+ * Point Prisma at a throwaway SQLite file and apply migrations to it.
+ * Must run before anything imports `src/store/db.ts`, because PrismaClient
+ * reads DATABASE_URL when it is constructed.
+ */
+export function prepareTestDatabase(name: string): string {
+  const dir = path.join(ROOT, 'data');
+  mkdirSync(dir, { recursive: true });
+  const file = path.join(dir, `${name}.db`);
+  rmSync(file, { force: true });
+  rmSync(`${file}-journal`, { force: true });
+
+  const url = `file:${file}`;
+  process.env.DATABASE_URL = url;
+
+  execFileSync('npx', ['prisma', 'migrate', 'deploy'], {
+    cwd: ROOT,
+    env: { ...process.env, DATABASE_URL: url },
+    stdio: 'pipe',
+  });
+
+  return url;
+}
+
+export interface RunningServer {
+  baseUrl: string;
+  close: () => Promise<void>;
+}
+
+export async function startServer(app: RequestListener): Promise<RunningServer> {
+  const server = createServer(app);
+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
+  const { port } = server.address() as AddressInfo;
+  return {
+    baseUrl: `http://127.0.0.1:${port}`,
+    close: () =>
+      new Promise<void>((resolve, reject) =>
+        server.close((err) => (err ? reject(err) : resolve())),
+      ),
+  };
+}

+ 98 - 0
tests/integration/jobQueue.test.ts

@@ -0,0 +1,98 @@
+import { after, before, beforeEach, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+
+const dbUrl = prepareTestDatabase('test-jobqueue');
+
+const { enqueue, claimNextJob, completeJob, failJob, queueStats } = await import(
+  '../../src/queue/jobQueue.js'
+);
+const { db, disconnectDb } = await import('../../src/store/db.js');
+
+before(() => applyTestConfig({ DATABASE_URL: dbUrl, WORKER_MAX_ATTEMPTS: '2' }));
+beforeEach(async () => {
+  await db().job.deleteMany({});
+});
+after(async () => {
+  await disconnectDb();
+});
+
+test('an enqueued job can be claimed exactly once', async () => {
+  await enqueue({ type: 'chatwoot_message', payload: { conversationId: 1 } });
+
+  const first = await claimNextJob();
+  assert.ok(first);
+  assert.equal(first.status, 'processing');
+  assert.equal(first.attempts, 1);
+
+  const second = await claimNextJob();
+  assert.equal(second, null, 'a claimed job must not be handed out again');
+});
+
+test('completing a job clears the error and marks it done', async () => {
+  const id = await enqueue({ type: 'chatwoot_message', payload: {} });
+  await claimNextJob();
+  await completeJob(id);
+
+  const job = await db().job.findUnique({ where: { id } });
+  assert.equal(job?.status, 'done');
+  assert.equal(job?.lastError, null);
+  assert.ok(job?.finishedAt);
+});
+
+test('a failure below the attempt limit is re-queued with a future runAfter', async () => {
+  const id = await enqueue({ type: 'chatwoot_message', payload: {} });
+  await claimNextJob();
+
+  const disposition = await failJob(id, 'Flowise returned HTTP 502');
+  assert.equal(disposition, 'retry');
+
+  const job = await db().job.findUnique({ where: { id } });
+  assert.equal(job?.status, 'queued');
+  assert.ok(job && job.runAfter.getTime() > Date.now(), 'backoff must delay the retry');
+  assert.match(job?.lastError ?? '', /502/);
+
+  const claimed = await claimNextJob();
+  assert.equal(claimed, null, 'a backed-off job is not runnable yet');
+});
+
+test('exhausting the attempt limit parks the job as dead', async () => {
+  const id = await enqueue({ type: 'chatwoot_message', payload: {} });
+
+  await claimNextJob();
+  assert.equal(await failJob(id, 'boom 1'), 'retry');
+
+  await db().job.update({ where: { id }, data: { runAfter: new Date(0) } });
+  await claimNextJob();
+  assert.equal(await failJob(id, 'boom 2'), 'dead');
+
+  const job = await db().job.findUnique({ where: { id } });
+  assert.equal(job?.status, 'dead');
+  assert.equal(job?.attempts, 2);
+});
+
+test('secrets never reach the stored payload or the stored error', async () => {
+  const id = await enqueue({
+    type: 'chatwoot_message',
+    payload: { api_token: 'super-secret-token', conversationId: 5 },
+  });
+  await claimNextJob();
+  await failJob(id, 'failed calling https://shop.test/x?consumer_key=ck_leakedvalue');
+
+  const job = await db().job.findUnique({ where: { id } });
+  assert.ok(!job?.payloadJson.includes('super-secret-token'));
+  assert.ok(!job?.lastError?.includes('ck_leakedvalue'));
+});
+
+test('queueStats counts jobs per status', async () => {
+  await enqueue({ type: 'chatwoot_message', payload: {} });
+  await enqueue({ type: 'chatwoot_message', payload: {} });
+  const id = await claimNextJob();
+  assert.ok(id);
+  await completeJob(id.id);
+
+  const stats = await queueStats();
+  assert.equal(stats.queued, 1);
+  assert.equal(stats.done, 1);
+});

+ 231 - 0
tests/integration/pipeline.test.ts

@@ -0,0 +1,231 @@
+import { after, before, beforeEach, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+import type { SupportMessageEvent } from '../../src/domain/messageNormalizer.js';
+import type { ChatwootClient } from '../../src/clients/chatwootClient.js';
+import type { FlowiseClient, FlowiseResult } from '../../src/clients/flowiseClient.js';
+
+const dbUrl = prepareTestDatabase('test-pipeline');
+
+const { processMessageEvent, buildFlowisePayload } = await import(
+  '../../src/domain/conversationPipeline.js'
+);
+const { db, disconnectDb } = await import('../../src/store/db.js');
+const { claimMessage } = await import('../../src/store/idempotencyStore.js');
+
+/** Records every Chatwoot side effect instead of performing it. */
+class FakeChatwoot {
+  sentMessages: { conversationId: number; content: string }[] = [];
+  labels: { conversationId: number; label: string }[] = [];
+  attributes: { conversationId: number; attrs: Record<string, unknown> }[] = [];
+  unassigned: number[] = [];
+  conversation: Record<string, unknown> = { id: 1311, labels: [], custom_attributes: {} };
+
+  async getConversation(): Promise<Record<string, unknown>> {
+    return this.conversation;
+  }
+  async addLabel(conversationId: number, label: string): Promise<void> {
+    this.labels.push({ conversationId, label });
+  }
+  async setCustomAttributes(conversationId: number, attrs: Record<string, unknown>): Promise<void> {
+    this.attributes.push({ conversationId, attrs });
+    const existing = (this.conversation.custom_attributes ?? {}) as Record<string, unknown>;
+    this.conversation.custom_attributes = { ...existing, ...attrs };
+  }
+  async unassignConversation(conversationId: number): Promise<boolean> {
+    this.unassigned.push(conversationId);
+    return true;
+  }
+  async sendOutgoingMessage(conversationId: number, content: string): Promise<unknown> {
+    this.sentMessages.push({ conversationId, content });
+    return {};
+  }
+}
+
+class FakeFlowise {
+  calls: unknown[] = [];
+  constructor(private readonly result: FlowiseResult) {}
+  async predict(payload: unknown): Promise<FlowiseResult> {
+    this.calls.push(payload);
+    return this.result;
+  }
+}
+
+function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent {
+  return {
+    source: 'chatwoot',
+    channel: 'email',
+    conversationId: 1311,
+    messageId: `m-${Math.random().toString(36).slice(2)}`,
+    inboxId: 1,
+    content: 'Czy macie gaz R134a?',
+    subject: 'Pytanie',
+    senderEmail: 'klient@example.com',
+    senderName: 'Klient Testowy',
+    senderId: '332',
+    labels: [],
+    customAttributes: {},
+    additionalAttributes: {},
+    attachmentCount: 0,
+    needsConversationFetch: false,
+    ...overrides,
+  };
+}
+
+const deps = (chatwoot: FakeChatwoot, flowise: FakeFlowise) => ({
+  chatwoot: chatwoot as unknown as ChatwootClient,
+  flowise: flowise as unknown as FlowiseClient,
+});
+
+before(() => applyTestConfig({ DATABASE_URL: dbUrl }));
+beforeEach(async () => {
+  await db().ticket.deleteMany({});
+  await db().auditEvent.deleteMany({});
+  await db().processedMessage.deleteMany({});
+});
+after(async () => {
+  await disconnectDb();
+});
+
+test('a normal question is answered and marked replied', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'Tak, mamy.', actions: null });
+  const event = evt();
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'reply');
+  assert.equal(chatwoot.sentMessages.length, 1);
+  assert.equal(chatwoot.sentMessages[0]?.content, 'Tak, mamy.');
+
+  const row = await db().processedMessage.findUnique({
+    where: { source_messageId: { source: 'chatwoot', messageId: event.messageId } },
+  });
+  assert.equal(row?.status, 'replied');
+});
+
+test('a conversation already in ticket mode never reaches Flowise', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'nie powinno wyjść', actions: null });
+  const event = evt({ labels: ['ticket'] });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.deepEqual(outcome, { action: 'skipped', reason: 'ticket_mode' });
+  assert.equal(flowise.calls.length, 0);
+  assert.equal(chatwoot.sentMessages.length, 0);
+});
+
+test('a ticket_number custom attribute also stops the AI', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'x', actions: null });
+  const event = evt({ customAttributes: { ticket_number: 'EKS-20260820-1311' } });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+  assert.equal(outcome.action, 'skipped');
+  assert.equal(flowise.calls.length, 0);
+});
+
+test('spam is blocked before Flowise and recorded', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'reply', text: 'x', actions: null });
+  const event = evt({ senderEmail: 'mailer-daemon@example.com' });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'spam');
+  assert.equal(flowise.calls.length, 0, 'the spam gate must sit in front of the LLM');
+  const row = await db().processedMessage.findUnique({
+    where: { source_messageId: { source: 'chatwoot', messageId: event.messageId } },
+  });
+  assert.equal(row?.status, 'spam');
+});
+
+test('a handoff action creates a ticket, labels it and replies with the number', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({
+    type: 'handoff',
+    text: 'Przekazuję sprawę do supportu.',
+    actions: [{ type: 'handoff' }],
+  });
+  const event = evt();
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'handoff');
+  assert.match((outcome as { ticketNumber: string }).ticketNumber, /^EKS-\d{8}-1311$/);
+  assert.ok(chatwoot.labels.some((l) => l.label === 'ticket'));
+  assert.ok(chatwoot.attributes.some((a) => 'ticket_number' in a.attrs));
+  assert.equal(chatwoot.sentMessages.length, 1);
+  assert.match(chatwoot.sentMessages[0]?.content ?? '', /EKS-\d{8}-1311/);
+});
+
+test('two handoffs for one conversation reuse the same ticket number', async () => {
+  const chatwoot = new FakeChatwoot();
+  const first = evt({ messageId: 'h1' });
+  const second = evt({ messageId: 'h2' });
+  await claimMessage(first.source, first.messageId, first.conversationId);
+  await claimMessage(second.source, second.messageId, second.conversationId);
+
+  const flowise = new FakeFlowise({ type: 'handoff', text: 'ok', actions: [{ type: 'handoff' }] });
+  const a = await processMessageEvent(first, deps(chatwoot, flowise));
+
+  // The second message would normally be stopped by the ticket-mode check; call
+  // createTicket directly to prove the idempotency of the ticket itself.
+  const { createTicket } = await import('../../src/domain/ticketService.js');
+  const b = await createTicket(1311, 'retry', chatwoot as unknown as ChatwootClient);
+
+  assert.equal((a as { ticketNumber: string }).ticketNumber, b.ticketNumber);
+  assert.equal(b.status, 'existing');
+  assert.equal(await db().ticket.count({ where: { conversationId: 1311 } }), 1);
+});
+
+test('an empty Flowise answer is recorded as failed and nothing is sent', async () => {
+  const chatwoot = new FakeChatwoot();
+  const flowise = new FakeFlowise({ type: 'unknown', text: null, actions: null });
+  const event = evt();
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'no_reply');
+  assert.equal(chatwoot.sentMessages.length, 0);
+  const row = await db().processedMessage.findUnique({
+    where: { source_messageId: { source: 'chatwoot', messageId: event.messageId } },
+  });
+  assert.equal(row?.status, 'failed');
+});
+
+test('the conversation is fetched when the webhook omitted labels', async () => {
+  const chatwoot = new FakeChatwoot();
+  chatwoot.conversation = { id: 1311, labels: ['ticket'], custom_attributes: {} };
+  const flowise = new FakeFlowise({ type: 'reply', text: 'x', actions: null });
+  const event = evt({ needsConversationFetch: true, labels: [] });
+  await claimMessage(event.source, event.messageId, event.conversationId);
+
+  const outcome = await processMessageEvent(event, deps(chatwoot, flowise));
+
+  assert.equal(outcome.action, 'skipped');
+  assert.equal(flowise.calls.length, 0);
+});
+
+test('the Flowise payload keeps the contract the custom tools depend on', () => {
+  const payload = buildFlowisePayload(evt());
+
+  assert.match(payload.question, /\[CONTACT_INFO\]/);
+  assert.match(payload.question, /contact_email: klient@example\.com/);
+  assert.match(payload.question, /Czy macie gaz R134a\?/);
+  assert.equal(payload.overrideConfig.sessionId, 'chatwoot:1311');
+  assert.equal(payload.overrideConfig.conversationId, 1311);
+  assert.equal(payload.overrideConfig.inboxId, 1);
+  assert.equal(payload.overrideConfig.tenantId, 'easyklima');
+  assert.equal(payload.overrideConfig.channel, 'email');
+  assert.equal(payload.metadata.source, 'chatwoot');
+  assert.equal(payload.metadata.messageType, 'incoming');
+});

+ 109 - 0
tests/integration/tools.test.ts

@@ -0,0 +1,109 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+
+const dbUrl = prepareTestDatabase('test-tools');
+
+const { createApp } = await import('../../src/http/app.js');
+const { disconnectDb } = await import('../../src/store/db.js');
+
+let running: RunningServer;
+
+const TOOL_PATHS = [
+  '/tools/get_order_data',
+  '/tools/get_product_data',
+  '/tools/get_shipping_data',
+  '/tools/get_payment_methods',
+  '/tools/get_product_compatibility',
+  '/tools/get_car_data',
+  '/tools/new_ticket',
+];
+
+before(async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl });
+  running = await startServer(createApp());
+});
+
+after(async () => {
+  await running.close();
+  await disconnectDb();
+});
+
+async function post(path: string, body: unknown, auth?: string) {
+  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+  if (auth !== undefined) headers.Authorization = auth;
+  const res = await fetch(`${running.baseUrl}${path}`, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify(body),
+  });
+  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
+}
+
+test('every tool endpoint exists and rejects an unauthenticated call', async () => {
+  for (const path of TOOL_PATHS) {
+    const res = await post(path, {});
+    assert.equal(res.status, 401, `${path} must require auth`);
+    assert.equal(res.body.code, 'UNAUTHORIZED');
+  }
+});
+
+test('a wrong bearer token is rejected', async () => {
+  for (const path of TOOL_PATHS) {
+    const res = await post(path, {}, 'Bearer wrong-secret');
+    assert.equal(res.status, 401, `${path} must reject a wrong secret`);
+  }
+});
+
+test('a token of the same length but different value is still rejected', async () => {
+  const res = await post('/tools/new_ticket', { conversationId: 1 }, 'Bearer test-shared-secreT');
+  assert.equal(res.status, 401);
+});
+
+test('an authenticated call with missing parameters returns 400, not 401', async () => {
+  const res = await post('/tools/new_ticket', {}, 'Bearer test-shared-secret');
+  assert.equal(res.status, 400);
+  assert.equal(res.body.code, 'MISSING_PARAMS');
+});
+
+test('get_order_data requires an e-mail for ownership verification', async () => {
+  const res = await post('/tools/get_order_data', { orderNumber: '123' }, 'Bearer test-shared-secret');
+  assert.equal(res.status, 400);
+  assert.equal(res.body.code, 'INVALID_PARAMS');
+});
+
+test('get_product_data requires at least one selector', async () => {
+  const res = await post('/tools/get_product_data', {}, 'Bearer test-shared-secret');
+  assert.equal(res.status, 400);
+  assert.equal(res.body.code, 'MISSING_PARAMS');
+});
+
+test('admin endpoints require the admin token, not the tool secret', async () => {
+  const noAuth = await fetch(`${running.baseUrl}/admin/events`);
+  assert.equal(noAuth.status, 401);
+
+  const toolSecret = await fetch(`${running.baseUrl}/admin/events`, {
+    headers: { Authorization: 'Bearer test-shared-secret' },
+  });
+  assert.equal(toolSecret.status, 401);
+
+  const admin = await fetch(`${running.baseUrl}/admin/events`, {
+    headers: { Authorization: 'Bearer test-admin-token' },
+  });
+  assert.equal(admin.status, 200);
+  const body = (await admin.json()) as Record<string, unknown>;
+  assert.equal(body.ok, true);
+  assert.ok(Array.isArray(body.events));
+});
+
+test('an unknown route returns a JSON 404', async () => {
+  const res = await fetch(`${running.baseUrl}/tools/does_not_exist`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-shared-secret' },
+    body: '{}',
+  });
+  assert.equal(res.status, 404);
+  const body = (await res.json()) as Record<string, unknown>;
+  assert.equal(body.code, 'NOT_FOUND');
+});

+ 129 - 0
tests/integration/webhook.test.ts

@@ -0,0 +1,129 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareTestDatabase, startServer, type RunningServer } from '../helpers/testServer.js';
+import { applyTestConfig } from '../helpers/testConfig.js';
+import {
+  incomingEmailMessage,
+  noLabelsPayload,
+  outgoingMessage,
+  privateNote,
+  statusUpdate,
+} from '../fixtures/chatwoot.js';
+
+const dbUrl = prepareTestDatabase('test-webhook');
+
+// Imported after the DATABASE_URL is in place.
+const { createApp } = await import('../../src/http/app.js');
+const { db, disconnectDb } = await import('../../src/store/db.js');
+
+let running: RunningServer;
+
+before(async () => {
+  applyTestConfig({ DATABASE_URL: dbUrl });
+  running = await startServer(createApp());
+});
+
+after(async () => {
+  await running.close();
+  await disconnectDb();
+});
+
+async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
+  const res = await fetch(`${running.baseUrl}${path}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', ...headers },
+    body: JSON.stringify(body),
+  });
+  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
+}
+
+test('GET /health returns 200 without touching any dependency', async () => {
+  const res = await fetch(`${running.baseUrl}/health`);
+  assert.equal(res.status, 200);
+  const body = (await res.json()) as Record<string, unknown>;
+  assert.equal(body.ok, true);
+  assert.equal(body.service, 'eks-relay');
+});
+
+test('an incoming message is accepted with 202 and recorded', async () => {
+  const res = await post('/webhooks/chatwoot', incomingEmailMessage);
+  assert.equal(res.status, 202);
+  assert.equal(res.body.accepted, true);
+  assert.equal(res.body.conversationId, 1311);
+  assert.ok(res.body.jobId);
+
+  const row = await db().processedMessage.findUnique({
+    where: { source_messageId: { source: 'chatwoot', messageId: '90210' } },
+  });
+  assert.ok(row, 'ProcessedMessage row should exist');
+  assert.equal(row.conversationId, 1311);
+  assert.equal(row.status, 'queued');
+
+  const jobs = await db().job.findMany({ where: { type: 'chatwoot_message' } });
+  assert.equal(jobs.length, 1);
+});
+
+test('the same message id a second time is a no-op duplicate', async () => {
+  const res = await post('/webhooks/chatwoot', incomingEmailMessage);
+  assert.equal(res.status, 200);
+  assert.equal(res.body.duplicate, true);
+
+  const jobs = await db().job.findMany({ where: { type: 'chatwoot_message' } });
+  assert.equal(jobs.length, 1, 'a duplicate must not create a second job');
+
+  const rows = await db().processedMessage.findMany({ where: { messageId: '90210' } });
+  assert.equal(rows.length, 1);
+});
+
+test('concurrent deliveries of the same message create exactly one job', async () => {
+  const payload = { ...incomingEmailMessage, id: 99001 };
+  const results = await Promise.all([
+    post('/webhooks/chatwoot', payload),
+    post('/webhooks/chatwoot', payload),
+    post('/webhooks/chatwoot', payload),
+  ]);
+  const accepted = results.filter((r) => r.status === 202);
+  assert.equal(accepted.length, 1, 'exactly one delivery may be accepted');
+
+  const jobs = await db().job.findMany({});
+  const forMessage = jobs.filter((j) => j.payloadJson.includes('"messageId":"99001"'));
+  assert.equal(forMessage.length, 1);
+});
+
+test('the webhook stores an audit event', async () => {
+  const events = await db().auditEvent.findMany({ where: { messageId: '90210' } });
+  assert.ok(events.length >= 1);
+  assert.equal(events[0]?.eventType, 'webhook_accepted');
+});
+
+test('outgoing messages, private notes and status events are skipped with 200', async () => {
+  for (const payload of [outgoingMessage, privateNote, statusUpdate]) {
+    const res = await post('/webhooks/chatwoot', payload);
+    assert.equal(res.status, 200);
+    assert.equal(res.body.skipped, true);
+  }
+  const skippedRows = await db().processedMessage.findMany({
+    where: { messageId: { in: ['90211', '90212', '90213'] } },
+  });
+  assert.equal(skippedRows.length, 0, 'skipped events must not be recorded as processed');
+});
+
+test('a payload without labels is still accepted and flagged for a fetch', async () => {
+  const res = await post('/webhooks/chatwoot', noLabelsPayload);
+  assert.equal(res.status, 202);
+
+  const job = await db().job.findFirst({
+    where: { payloadJson: { contains: '"messageId":"90217"' } },
+  });
+  assert.ok(job);
+  const payload = JSON.parse(job.payloadJson) as { needsConversationFetch: boolean };
+  assert.equal(payload.needsConversationFetch, true);
+});
+
+test('garbage bodies are answered 200 without creating work', async () => {
+  const before = await db().job.count();
+  const res = await post('/webhooks/chatwoot', { totally: 'unrelated' });
+  assert.equal(res.status, 200);
+  assert.equal(res.body.skipped, true);
+  assert.equal(await db().job.count(), before);
+});

+ 60 - 0
tests/logger.test.ts

@@ -0,0 +1,60 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { maskEmail, redact, redactString } from '../src/logger.js';
+
+test('bearer tokens are redacted', () => {
+  const out = redactString('Authorization: Bearer sk-abc123DEF456ghi', false);
+  assert.ok(!out.includes('sk-abc123DEF456ghi'));
+  assert.match(out, /Bearer \[REDACTED\]/);
+});
+
+test('WooCommerce consumer keys are redacted', () => {
+  const out = redactString('ck_1234567890abcdef and cs_abcdef1234567890', false);
+  assert.ok(!out.includes('ck_1234567890abcdef'));
+  assert.ok(!out.includes('cs_abcdef1234567890'));
+});
+
+test('query-string credentials are redacted', () => {
+  const out = redactString(
+    'https://shop.test/wp-json/wc/v3/orders?consumer_key=ck_secretvalue&consumer_secret=cs_secretvalue',
+    false,
+  );
+  assert.ok(!out.includes('ck_secretvalue'));
+  assert.ok(!out.includes('cs_secretvalue'));
+});
+
+test('e-mail addresses are redacted when PII logging is off', () => {
+  const out = redactString('contact jan.kowalski@example.com about order', false);
+  assert.ok(!out.includes('jan.kowalski@example.com'));
+});
+
+test('secret-looking object keys are always redacted, even with PII logging on', () => {
+  const out = redact(
+    { api_token: 'abc', consumer_secret: 'def', nested: { password: 'ghi' } },
+    true,
+  ) as Record<string, unknown>;
+  assert.equal(out.api_token, '[REDACTED]');
+  assert.equal(out.consumer_secret, '[REDACTED]');
+  assert.equal((out.nested as Record<string, unknown>).password, '[REDACTED]');
+});
+
+test('PII keys are masked when PII logging is off', () => {
+  const out = redact({ email: 'jan@example.com', content: 'treść wiadomości' }, false) as Record<
+    string,
+    unknown
+  >;
+  assert.equal(out.email, 'j***@example.com');
+  assert.equal(out.content, '[REDACTED_PII]');
+});
+
+test('maskEmail keeps only the first character and the domain', () => {
+  assert.equal(maskEmail('jan.kowalski@example.com'), 'j***@example.com');
+  assert.equal(maskEmail('not-an-email'), '[REDACTED]');
+});
+
+test('redact terminates on deeply nested structures', () => {
+  type Nested = { next?: Nested };
+  let deep: Nested = {};
+  for (let i = 0; i < 30; i++) deep = { next: deep };
+  assert.doesNotThrow(() => redact(deep, false));
+});

+ 99 - 0
tests/messageNormalizer.test.ts

@@ -0,0 +1,99 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { cleanEmailBody, normalizeChatwootWebhook } from '../src/domain/messageNormalizer.js';
+import {
+  bounceMessage,
+  incomingEmailMessage,
+  missingConversationId,
+  noLabelsPayload,
+  outgoingMessage,
+  privateNote,
+  statusUpdate,
+} from './fixtures/chatwoot.js';
+
+test('accepts an incoming e-mail message', () => {
+  const result = normalizeChatwootWebhook(incomingEmailMessage);
+  assert.ok(result.ok);
+  assert.equal(result.event.conversationId, 1311);
+  assert.equal(result.event.messageId, '90210');
+  assert.equal(result.event.senderEmail, 'jan.kowalski@example.com');
+  assert.equal(result.event.senderName, 'Jan Kowalski');
+  assert.equal(result.event.channel, 'email');
+  assert.equal(result.event.subject, 'Pytanie o klimatyzację');
+  assert.equal(result.event.needsConversationFetch, false);
+});
+
+test('rejects outgoing messages', () => {
+  const result = normalizeChatwootWebhook(outgoingMessage);
+  assert.equal(result.ok, false);
+  assert.match((result as { reason: string }).reason, /not_incoming/);
+});
+
+test('rejects private notes', () => {
+  const result = normalizeChatwootWebhook(privateNote);
+  assert.equal(result.ok, false);
+  assert.equal((result as { reason: string }).reason, 'private_note');
+});
+
+test('rejects non message_created events', () => {
+  const result = normalizeChatwootWebhook(statusUpdate);
+  assert.equal(result.ok, false);
+  assert.match((result as { reason: string }).reason, /unsupported_event/);
+});
+
+test('rejects payloads without a conversation id', () => {
+  const result = normalizeChatwootWebhook(missingConversationId);
+  assert.equal(result.ok, false);
+  assert.equal((result as { reason: string }).reason, 'missing_conversation_id');
+});
+
+test('flags payloads that need a conversation fetch', () => {
+  const result = normalizeChatwootWebhook(noLabelsPayload);
+  assert.ok(result.ok);
+  assert.equal(result.event.needsConversationFetch, true);
+});
+
+test('derives a stable surrogate id when the payload has no message id', () => {
+  const { id, ...withoutId } = incomingEmailMessage;
+  void id;
+  const a = normalizeChatwootWebhook(withoutId);
+  const b = normalizeChatwootWebhook(withoutId);
+  assert.ok(a.ok && b.ok);
+  assert.match(a.event.messageId, /^hash:1311:[0-9a-f]{32}$/);
+  assert.equal(a.event.messageId, b.event.messageId);
+});
+
+test('a different body yields a different surrogate id', () => {
+  const { id, ...withoutId } = incomingEmailMessage;
+  void id;
+  const a = normalizeChatwootWebhook(withoutId);
+  const b = normalizeChatwootWebhook({ ...withoutId, content: 'inna treść' });
+  assert.ok(a.ok && b.ok);
+  assert.notEqual(a.event.messageId, b.event.messageId);
+});
+
+test('bounce payloads still normalise (the spam gate decides, not the parser)', () => {
+  const result = normalizeChatwootWebhook(bounceMessage);
+  assert.ok(result.ok);
+  assert.equal(result.event.senderEmail, 'mailer-daemon@example.com');
+});
+
+test('cleanEmailBody strips quoted replies and signatures', () => {
+  const raw = [
+    'Dzień dobry, mam pytanie o zamówienie.',
+    '',
+    'W dniu 2026-08-19 Jan Kowalski napisał:',
+    '> poprzednia wiadomość',
+    '> druga linia cytatu',
+  ].join('\n');
+  assert.equal(cleanEmailBody(raw), 'Dzień dobry, mam pytanie o zamówienie.');
+});
+
+test('cleanEmailBody converts HTML e-mails to text', () => {
+  const raw = '<p>Witam,<br>czy produkt jest dostępny?</p><style>p{color:red}</style>';
+  const cleaned = cleanEmailBody(raw);
+  assert.match(cleaned, /Witam/);
+  assert.match(cleaned, /czy produkt jest dostępny\?/);
+  assert.ok(!cleaned.includes('<'));
+  assert.ok(!cleaned.includes('color:red'));
+});

+ 89 - 0
tests/spamGate.test.ts

@@ -0,0 +1,89 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { evaluateSpam } from '../src/domain/spamGate.js';
+import type { SupportMessageEvent } from '../src/domain/messageNormalizer.js';
+
+function evt(overrides: Partial<SupportMessageEvent> = {}): SupportMessageEvent {
+  return {
+    source: 'chatwoot',
+    channel: 'email',
+    conversationId: 1311,
+    messageId: '1',
+    inboxId: 1,
+    content: 'Czy ten produkt pasuje do mojego auta?',
+    subject: 'Pytanie',
+    senderEmail: 'klient@example.com',
+    senderName: 'Klient',
+    senderId: '1',
+    labels: [],
+    customAttributes: {},
+    additionalAttributes: {},
+    attachmentCount: 0,
+    needsConversationFetch: false,
+    ...overrides,
+  };
+}
+
+test('a normal customer question passes', () => {
+  assert.equal(evaluateSpam(evt()).spam, false);
+});
+
+test('mailer-daemon is blocked', () => {
+  const v = evaluateSpam(evt({ senderEmail: 'mailer-daemon@example.com' }));
+  assert.equal(v.spam, true);
+  assert.match(v.reason, /automated_sender/);
+});
+
+test('no-reply senders are blocked', () => {
+  assert.equal(evaluateSpam(evt({ senderEmail: 'no-reply@shop.example' })).spam, true);
+  assert.equal(evaluateSpam(evt({ senderEmail: 'noreply@shop.example' })).spam, true);
+});
+
+test('bounce subjects are blocked', () => {
+  const v = evaluateSpam(evt({ subject: 'Undelivered Mail Returned to Sender' }));
+  assert.equal(v.spam, true);
+  assert.equal(v.reason, 'bounce_subject');
+});
+
+test('out-of-office autoreplies are blocked', () => {
+  assert.equal(evaluateSpam(evt({ subject: 'Automatic reply: your message' })).spam, true);
+  assert.equal(evaluateSpam(evt({ subject: 'Automatyczna odpowiedź' })).spam, true);
+});
+
+test('DMARC aggregate reports are blocked', () => {
+  assert.equal(evaluateSpam(evt({ subject: 'Report domain: easyklima.com' })).spam, true);
+});
+
+test('empty bodies and attachment-only mails are blocked', () => {
+  assert.equal(evaluateSpam(evt({ content: '' })).reason, 'empty_body');
+  assert.equal(evaluateSpam(evt({ content: '', attachmentCount: 2 })).reason, 'attachment_only');
+});
+
+test('auto-submitted headers are blocked', () => {
+  const v = evaluateSpam(
+    evt({ additionalAttributes: { headers: { 'Auto-Submitted': 'auto-replied' } } }),
+  );
+  assert.equal(v.spam, true);
+  assert.equal(v.reason, 'header:auto-submitted');
+});
+
+test('auto-submitted: no is NOT treated as automated', () => {
+  const v = evaluateSpam(evt({ additionalAttributes: { headers: { 'Auto-Submitted': 'no' } } }));
+  assert.equal(v.spam, false);
+});
+
+test('list-unsubscribe marks a newsletter', () => {
+  const v = evaluateSpam(
+    evt({ additionalAttributes: { headers: { 'List-Unsubscribe': '<mailto:x@y.z>' } } }),
+  );
+  assert.equal(v.spam, true);
+});
+
+test('a body with no words is blocked', () => {
+  assert.equal(evaluateSpam(evt({ content: '??? !!! ...' })).reason, 'no_textual_content');
+});
+
+test('a legitimate mail from an address merely containing "notification" passes', () => {
+  // Only exact local-part matches are automated; substrings must not trigger.
+  assert.equal(evaluateSpam(evt({ senderEmail: 'notifications.jan@example.com' })).spam, false);
+});

+ 63 - 0
tests/storeLocales.test.ts

@@ -0,0 +1,63 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { applyTestConfig } from './helpers/testConfig.js';
+import {
+  currencyMeta,
+  isSupportedCurrency,
+  resolveCurrency,
+  resolveLocale,
+} from '../src/domain/storeLocales.js';
+
+test('language maps to its natural currency when the shop sells in it', () => {
+  applyTestConfig();
+  assert.equal(resolveLocale('pl').currency, 'PLN');
+  assert.equal(resolveLocale('cs').currency, 'CZK');
+  assert.equal(resolveLocale('de').currency, 'EUR');
+});
+
+test('unsupported natural currency falls back to EUR with a note', () => {
+  applyTestConfig();
+  const locale = resolveLocale('tr');
+  assert.equal(locale.currency, 'EUR');
+  assert.equal(locale.fallback, true);
+  assert.equal(locale.reason, 'unsupported_currency');
+  assert.equal(locale.naturalCurrency, 'TRY');
+  assert.match(String(currencyMeta(locale, 'tr').currency_note), /TRY/);
+});
+
+test('an unknown language code falls back to EUR', () => {
+  applyTestConfig();
+  const locale = resolveLocale('xx');
+  assert.equal(locale.currency, 'EUR');
+  assert.equal(locale.reason, 'unknown_language');
+});
+
+test('no language at all is EUR without a fallback flag', () => {
+  applyTestConfig();
+  const locale = resolveLocale(null);
+  assert.equal(locale.currency, 'EUR');
+  assert.equal(locale.fallback, false);
+});
+
+test('an explicit supported currency override wins over the language', () => {
+  applyTestConfig();
+  assert.equal(resolveCurrency('pl', 'CZK').currency, 'CZK');
+});
+
+test('an unsupported currency override is ignored, language mapping applies', () => {
+  applyTestConfig();
+  assert.equal(resolveCurrency('pl', 'JPY').currency, 'PLN');
+});
+
+test('STORE_CURRENCIES drives what counts as supported', () => {
+  applyTestConfig({ STORE_CURRENCIES: 'PLN,EUR' });
+  assert.equal(isSupportedCurrency('CZK'), false);
+  assert.equal(resolveLocale('cs').currency, 'EUR');
+  assert.equal(resolveLocale('cs').fallback, true);
+});
+
+test('currencyMeta omits the fallback fields on a clean resolution', () => {
+  applyTestConfig();
+  const meta = currencyMeta(resolveLocale('pl'), 'pl');
+  assert.deepEqual(meta, { currency: 'PLN' });
+});

+ 40 - 0
tests/ticketService.test.ts

@@ -0,0 +1,40 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { applyTestConfig } from './helpers/testConfig.js';
+import { formatTicketNumber, isTicketMode } from '../src/domain/ticketService.js';
+
+test('ticket numbers use the EKS-YYYYMMDD-<conversationId> format', () => {
+  applyTestConfig();
+  const n = formatTicketNumber(1311, new Date(Date.UTC(2026, 7, 20, 9, 0, 0)));
+  assert.equal(n, 'EKS-20260820-1311');
+});
+
+test('the ticket prefix is configurable', () => {
+  applyTestConfig({ TICKET_NUMBER_PREFIX: 'SUP' });
+  assert.equal(formatTicketNumber(7, new Date(Date.UTC(2026, 0, 2))), 'SUP-20260102-7');
+});
+
+test('the ticket label puts a conversation in manual mode', () => {
+  applyTestConfig();
+  assert.equal(isTicketMode({ labels: ['ticket'] }), true);
+  assert.equal(isTicketMode({ labels: ['vip'] }), false);
+});
+
+test('handoff=true puts a conversation in manual mode', () => {
+  applyTestConfig();
+  assert.equal(isTicketMode({ custom_attributes: { handoff: true } }), true);
+  assert.equal(isTicketMode({ custom_attributes: { handoff: 'true' } }), true);
+  assert.equal(isTicketMode({ custom_attributes: { handoff: false } }), false);
+});
+
+test('a real ticket_number puts a conversation in manual mode, "0" does not', () => {
+  applyTestConfig();
+  assert.equal(isTicketMode({ custom_attributes: { ticket_number: 'EKS-20260820-1311' } }), true);
+  assert.equal(isTicketMode({ custom_attributes: { ticket_number: '0' } }), false);
+  assert.equal(isTicketMode({ custom_attributes: { ticket_number: '' } }), false);
+});
+
+test('an empty conversation is not in manual mode', () => {
+  applyTestConfig();
+  assert.equal(isTicketMode({}), false);
+});

+ 8 - 0
tsconfig.check.json

@@ -0,0 +1,8 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "noEmit": true,
+    "rootDir": "."
+  },
+  "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
+}

+ 21 - 0
tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2023",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "lib": ["ES2023"],
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noImplicitOverride": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "sourceMap": true,
+    "declaration": false
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["node_modules", "dist"]
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.