Compare commits
140 Commits
1b3b215bff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78f49171c | ||
|
|
762ec0b56f | ||
|
|
42de04c1f6 | ||
|
|
64e0d7565f | ||
|
|
44f4e9d90d | ||
|
|
a2dd7b0e76 | ||
|
|
39cf198e11 | ||
|
|
2722aa860d | ||
|
|
bb2fab8b40 | ||
|
|
8eec280b9d | ||
|
|
08a31383f0 | ||
|
|
9283ab436f | ||
|
|
25623b8f65 | ||
|
|
29309419bf | ||
|
|
035f24387b | ||
|
|
2ca1e75651 | ||
|
|
e96b57a55f | ||
|
|
c2cf4e6dd8 | ||
|
|
ad0f61fa8d | ||
|
|
22e04e0a34 | ||
|
|
f1cf90adc7 | ||
|
|
e4870ce669 | ||
|
|
0df426d5d6 | ||
|
|
7d1bed0d67 | ||
|
|
fe4bd59248 | ||
|
|
12af9979ab | ||
|
|
881a8c6d39 | ||
|
|
b2a948889e | ||
|
|
aae2a03340 | ||
|
|
0a470d3922 | ||
|
|
5063dfdecf | ||
|
|
a0ff1d00f6 | ||
|
|
97fb67f68c | ||
|
|
6bae0300c8 | ||
|
|
ba3e5f7cac | ||
|
|
0f87586e81 | ||
|
|
6291797bb6 | ||
|
|
f4891e6932 | ||
|
|
5c29cde13d | ||
|
|
693a96cffd | ||
|
|
bf7f4ae933 | ||
|
|
b830f3728c | ||
|
|
5ff7dc8d65 | ||
|
|
7d647bef25 | ||
|
|
9b6e8291fe | ||
|
|
6e3763a5fd | ||
|
|
292d587fe1 | ||
|
|
1a6840cdc6 | ||
|
|
898f0dc0c5 | ||
|
|
cb685446a5 | ||
|
|
3ff9120070 | ||
|
|
c07ef2026d | ||
|
|
5492e0d05c | ||
|
|
643d8d02ba | ||
|
|
ac9c50b47d | ||
|
|
601de37ab0 | ||
|
|
c229bdee23 | ||
|
|
3775d881f9 | ||
|
|
195df8e16a | ||
|
|
19d001815c | ||
|
|
d892d0c604 | ||
|
|
a4d8d81de9 | ||
|
|
e5ad3809e0 | ||
|
|
00e036946c | ||
|
|
9505cecab2 | ||
|
|
77141978c5 | ||
|
|
227030b9ae | ||
|
|
638652b4d8 | ||
|
|
f553c26931 | ||
|
|
5657da13c1 | ||
|
|
947ef4d56d | ||
|
|
3e711a5533 | ||
|
|
b316b024be | ||
|
|
1db8e58da1 | ||
|
|
6cce211c0b | ||
|
|
c5d3a90413 | ||
|
|
c1e8f912d1 | ||
|
|
ed78532260 | ||
|
|
94d8d46693 | ||
|
|
79f1012f41 | ||
|
|
faea65dfcb | ||
|
|
bb628a7c0d | ||
|
|
2e1014d726 | ||
|
|
5fb8113ed7 | ||
|
|
2a3d18f326 | ||
|
|
0ed2a6b353 | ||
|
|
179cc39d53 | ||
|
|
6ab3b374a2 | ||
|
|
49c4757490 | ||
|
|
67a186e916 | ||
|
|
6d5402dcc1 | ||
|
|
295b3a3dda | ||
|
|
94c01516ba | ||
|
|
2eb2f3109c | ||
|
|
6bc0bfa156 | ||
|
|
cb2d12819c | ||
|
|
0bbeef5594 | ||
|
|
df8c06d313 | ||
|
|
f716a0ea26 | ||
|
|
7c019a6300 | ||
|
|
ec94dd6e2a | ||
|
|
40a225783d | ||
|
|
60b9bb9fd1 | ||
|
|
f6b738352b | ||
|
|
db49c4a830 | ||
|
|
6ad53e64c5 | ||
|
|
68cbe7bc64 | ||
|
|
a19ba07baa | ||
|
|
894210cd42 | ||
|
|
d3b751db65 | ||
|
|
aa465f65bd | ||
|
|
f076726362 | ||
|
|
acd974766a | ||
|
|
c94c229a1a | ||
|
|
43960d0374 | ||
|
|
5918a0593d | ||
|
|
8be6e7d581 | ||
|
|
82bc5dd04e | ||
|
|
d5f7280297 | ||
|
|
2b72d42956 | ||
|
|
47ed805ac7 | ||
|
|
e5030a321f | ||
|
|
64b25bb189 | ||
|
|
6e40c96abd | ||
|
|
70369255a2 | ||
|
|
2b5aab1210 | ||
|
|
f67cef22be | ||
|
|
4b9682e447 | ||
|
|
c9e4c3172e | ||
|
|
95fd9a64ce | ||
|
|
6bc154a1e6 | ||
|
|
23d8035571 | ||
|
|
21d6e440e3 | ||
|
|
d4af315e2e | ||
|
|
c41849745c | ||
|
|
9c712e0129 | ||
|
|
8ef266e09d | ||
|
|
43b487ccec | ||
|
|
ab5370c831 | ||
|
|
eb298e786e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ coverage
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
frontend/server/generated
|
||||||
|
|||||||
12
.gitmodules
vendored
12
.gitmodules
vendored
@@ -1,3 +1,15 @@
|
|||||||
[submodule "instructions"]
|
[submodule "instructions"]
|
||||||
path = instructions
|
path = instructions
|
||||||
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
||||||
|
[submodule "frontend"]
|
||||||
|
path = frontend
|
||||||
|
url = git@gitea.dsrptlab.com:clientflow/frontend.git
|
||||||
|
branch = main
|
||||||
|
[submodule "backend"]
|
||||||
|
path = backend
|
||||||
|
url = git@gitea.dsrptlab.com:clientflow/backend.git
|
||||||
|
branch = main
|
||||||
|
[submodule "backend_worker"]
|
||||||
|
path = backend_worker
|
||||||
|
url = git@gitea.dsrptlab.com:clientflow/backend_worker.git
|
||||||
|
branch = main
|
||||||
|
|||||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at 32a5f949d9
1
backend_worker
Submodule
1
backend_worker
Submodule
Submodule backend_worker added at 3f562f3d08
@@ -2,7 +2,9 @@ version = 1
|
|||||||
|
|
||||||
[services]
|
[services]
|
||||||
frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
omni_outbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
omni_inbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
backend_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
omni_chat = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
telegram_backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
langfuse = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "langfuse/docker-compose.yml" }
|
telegram_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
|
hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }
|
||||||
|
vault = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
services:
|
|
||||||
langfuse-worker:
|
|
||||||
image: docker.io/langfuse/langfuse-worker:3
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
langfuse-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-minio:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-redis:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-clickhouse:
|
|
||||||
condition: service_healthy
|
|
||||||
environment: &langfuse_env
|
|
||||||
NEXTAUTH_URL: "http://localhost:3001"
|
|
||||||
DATABASE_URL: "postgresql://langfuse:langfuse@langfuse-postgres:5432/langfuse"
|
|
||||||
SALT: "clientsflow-local-salt"
|
|
||||||
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
||||||
TELEMETRY_ENABLED: "false"
|
|
||||||
CLICKHOUSE_MIGRATION_URL: "clickhouse://langfuse-clickhouse:9000"
|
|
||||||
CLICKHOUSE_URL: "http://langfuse-clickhouse:8123"
|
|
||||||
CLICKHOUSE_USER: "clickhouse"
|
|
||||||
CLICKHOUSE_PASSWORD: "clickhouse"
|
|
||||||
CLICKHOUSE_CLUSTER_ENABLED: "false"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "langfuse"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "minio"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
|
|
||||||
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: "events/"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: "langfuse"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_REGION: "auto"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: "minio"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
|
|
||||||
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "media/"
|
|
||||||
REDIS_HOST: "langfuse-redis"
|
|
||||||
REDIS_PORT: "6379"
|
|
||||||
REDIS_AUTH: "langfuse-redis"
|
|
||||||
REDIS_TLS_ENABLED: "false"
|
|
||||||
|
|
||||||
langfuse-web:
|
|
||||||
image: docker.io/langfuse/langfuse:3
|
|
||||||
restart: always
|
|
||||||
depends_on:
|
|
||||||
langfuse-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-minio:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-redis:
|
|
||||||
condition: service_healthy
|
|
||||||
langfuse-clickhouse:
|
|
||||||
condition: service_healthy
|
|
||||||
expose:
|
|
||||||
- "3000"
|
|
||||||
environment:
|
|
||||||
<<: *langfuse_env
|
|
||||||
NEXTAUTH_SECRET: "clientsflow-local-nextauth-secret"
|
|
||||||
LANGFUSE_INIT_ORG_ID: "org-clientsflow"
|
|
||||||
LANGFUSE_INIT_ORG_NAME: "Clientsflow Local"
|
|
||||||
LANGFUSE_INIT_PROJECT_ID: "proj-clientsflow"
|
|
||||||
LANGFUSE_INIT_PROJECT_NAME: "clientsflow"
|
|
||||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-local"
|
|
||||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-local"
|
|
||||||
LANGFUSE_INIT_USER_EMAIL: "admin@clientsflow.local"
|
|
||||||
LANGFUSE_INIT_USER_NAME: "Local Admin"
|
|
||||||
LANGFUSE_INIT_USER_PASSWORD: "clientsflow-local-admin"
|
|
||||||
|
|
||||||
langfuse-clickhouse:
|
|
||||||
image: docker.io/clickhouse/clickhouse-server:latest
|
|
||||||
restart: always
|
|
||||||
user: "101:101"
|
|
||||||
environment:
|
|
||||||
CLICKHOUSE_DB: "default"
|
|
||||||
CLICKHOUSE_USER: "clickhouse"
|
|
||||||
CLICKHOUSE_PASSWORD: "clickhouse"
|
|
||||||
volumes:
|
|
||||||
- langfuse_clickhouse_data:/var/lib/clickhouse
|
|
||||||
- langfuse_clickhouse_logs:/var/log/clickhouse-server
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
langfuse-minio:
|
|
||||||
image: cgr.dev/chainguard/minio:latest
|
|
||||||
restart: always
|
|
||||||
entrypoint: sh
|
|
||||||
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: "minio"
|
|
||||||
MINIO_ROOT_PASSWORD: "miniosecret"
|
|
||||||
volumes:
|
|
||||||
- langfuse_minio_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
|
||||||
interval: 2s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 15
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
langfuse-redis:
|
|
||||||
image: docker.io/redis:7-alpine
|
|
||||||
restart: always
|
|
||||||
command: ["redis-server", "--requirepass", "langfuse-redis", "--maxmemory-policy", "noeviction"]
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "redis-cli -a langfuse-redis ping | grep PONG"]
|
|
||||||
interval: 3s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
langfuse-postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: "langfuse"
|
|
||||||
POSTGRES_USER: "langfuse"
|
|
||||||
POSTGRES_PASSWORD: "langfuse"
|
|
||||||
volumes:
|
|
||||||
- langfuse_postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U langfuse -d langfuse"]
|
|
||||||
interval: 3s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 20
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
langfuse_postgres_data:
|
|
||||||
langfuse_clickhouse_data:
|
|
||||||
langfuse_clickhouse_logs:
|
|
||||||
langfuse_minio_data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
dokploy-network:
|
|
||||||
external: true
|
|
||||||
@@ -1,104 +1,109 @@
|
|||||||
# ADR-0001: Разделение Chat Platform на 3 сервиса
|
# ADR-0001: Chat Platform Boundaries (GraphQL + Hatchet)
|
||||||
|
|
||||||
Дата: 2026-02-21
|
Дата: 2026-03-08
|
||||||
Статус: accepted
|
Статус: accepted
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`.
|
Нужна минимальная и предсказуемая схема из 6 сервисов:
|
||||||
Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга.
|
|
||||||
|
|
||||||
Критичные требования:
|
- `frontend`
|
||||||
|
- `backend`
|
||||||
|
- `backend_worker`
|
||||||
|
- `telegram_backend`
|
||||||
|
- `telegram_worker`
|
||||||
|
- `hatchet`
|
||||||
|
|
||||||
- входящие webhook-события не теряются при рестартах;
|
Ключевые ограничения:
|
||||||
- delivery управляет retry/rate-limit централизованно;
|
|
||||||
- omni_chat остается единственным местом доменной логики и хранения состояния диалогов;
|
- основная Prisma/доменная БД только в `backend`;
|
||||||
- сервисы можно обновлять независимо.
|
- `telegram_backend` и `telegram_worker` не содержат CRM-домен и не пишут в основную БД;
|
||||||
|
- взаимодействие между сервисами только через GraphQL;
|
||||||
|
- асинхронность и ретраи централизованы в Hatchet.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
Принимаем разделение на 3 сервиса:
|
Принимаем архитектуру:
|
||||||
|
|
||||||
1. `omni_inbound`
|
1. `backend`
|
||||||
- Принимает вебхуки провайдеров.
|
- владеет доменной моделью чатов и единственной основной Prisma-базой;
|
||||||
- Валидирует подпись/секрет.
|
- принимает inbound события от `telegram_worker` через GraphQL (`ingestTelegramInbound`);
|
||||||
- Нормализует событие в универсальный envelope.
|
- создает outbound задачи в `telegram_backend` через GraphQL (`requestTelegramOutbound`);
|
||||||
- Пишет событие в durable queue (`receiver.flow`) с идемпотентным `jobId`.
|
- принимает delivery-отчеты от `telegram_worker` через GraphQL (`reportTelegramOutbound`).
|
||||||
- Возвращает `200` только после успешной durable enqueue.
|
|
||||||
- Не содержит бизнес-логики CRM.
|
|
||||||
|
|
||||||
2. `omni_chat`
|
2. `telegram_backend`
|
||||||
- Потребляет входящие события из `receiver.flow`.
|
- принимает webhook Telegram;
|
||||||
- Разрешает идентичности и треды.
|
- нормализует payload в `OmniInboundEnvelopeV1`;
|
||||||
- Создает/обновляет `OmniMessage`, `OmniThread`, статусы и доменные эффекты.
|
- ставит задачи в Hatchet (`process-telegram-inbound`, `process-telegram-outbound`);
|
||||||
- Формирует исходящие команды и кладет их в `sender.flow`.
|
- предоставляет GraphQL API для enqueue и отправки в Telegram API.
|
||||||
|
|
||||||
3. `omni_outbound`
|
3. `telegram_worker`
|
||||||
- Потребляет `sender.flow`.
|
- исполняет задачи Hatchet;
|
||||||
- Выполняет отправку в провайдеров (Telegram Business и др.).
|
- для inbound вызывает `backend /graphql`;
|
||||||
- Управляет retry/backoff/failover, DLQ и статусами доставки.
|
- для outbound вызывает `telegram_backend /graphql` (`sendTelegramMessage`), затем `backend /graphql` (`reportTelegramOutbound`);
|
||||||
- Не содержит UI и доменной логики чатов.
|
- не имеет собственной Prisma-базы.
|
||||||
|
|
||||||
## Почему webhook и delivery разделены
|
4. `backend_worker`
|
||||||
|
- исполняет периодические backend workflow в Hatchet;
|
||||||
|
- для cron-задач вызывает `backend /graphql` (без прямого доступа к Prisma).
|
||||||
|
|
||||||
- Входящий контур должен отвечать быстро и предсказуемо.
|
5. `hatchet`
|
||||||
- Исходящий контур живет с долгими retry и ограничениями провайдера.
|
- единый оркестратор задач, ретраев и backoff-политик.
|
||||||
- Сбой внешнего API не должен блокировать прием входящих сообщений.
|
|
||||||
|
## Потоки
|
||||||
|
|
||||||
|
### Inbound (Telegram -> CRM)
|
||||||
|
|
||||||
|
1. Telegram webhook приходит в `telegram_backend`.
|
||||||
|
2. `telegram_backend` нормализует событие и enqueue в Hatchet `process-telegram-inbound`.
|
||||||
|
3. `telegram_worker` исполняет задачу и вызывает `backend.ingestTelegramInbound`.
|
||||||
|
4. `backend` сохраняет доменные изменения в своей БД.
|
||||||
|
|
||||||
|
### Outbound (CRM -> Telegram)
|
||||||
|
|
||||||
|
1. `backend` инициирует отправку (`requestTelegramOutbound`) в `telegram_backend`.
|
||||||
|
2. `telegram_backend` enqueue в Hatchet `process-telegram-outbound`.
|
||||||
|
3. `telegram_worker` вызывает `telegram_backend.sendTelegramMessage`.
|
||||||
|
4. `telegram_worker` репортит итог в `backend.reportTelegramOutbound`.
|
||||||
|
|
||||||
|
### Calendar Predue (Backend cron)
|
||||||
|
|
||||||
|
1. Hatchet по cron запускает workflow в `backend_worker`.
|
||||||
|
2. `backend_worker` вызывает `backend.syncCalendarPredueTimeline`.
|
||||||
|
3. `backend` делает upsert `ClientTimelineEntry` для `CalendarEvent` в окне `startsAt - preDue`.
|
||||||
|
|
||||||
## Границы ответственности
|
## Границы ответственности
|
||||||
|
|
||||||
`omni_inbound`:
|
`backend`:
|
||||||
|
- можно: вся бизнес-логика и состояние;
|
||||||
|
- нельзя: прямой вызов Telegram API.
|
||||||
|
|
||||||
- можно: auth, валидация, нормализация, дедуп, enqueue;
|
`telegram_backend`:
|
||||||
- нельзя: запись доменных сущностей CRM, принятие продуктовых решений.
|
- можно: webhook ingress, нормализация, enqueue, адаптер Telegram API;
|
||||||
|
- нельзя: доменные записи CRM.
|
||||||
|
|
||||||
`omni_chat`:
|
`telegram_worker`:
|
||||||
|
- можно: исполнение задач, ретраи, orchestration шагов;
|
||||||
|
- нельзя: хранение CRM-состояния и прямой доступ к основной БД.
|
||||||
|
|
||||||
- можно: вся доменная модель чатов, orchestration, бизнес-правила;
|
`backend_worker`:
|
||||||
- нельзя: прямые вызовы провайдеров из sync API-контекста.
|
- можно: периодические orchestration задачи через Hatchet;
|
||||||
|
- нельзя: прямой доступ к основной БД (только через backend GraphQL).
|
||||||
|
|
||||||
`omni_outbound`:
|
## Надежность
|
||||||
|
|
||||||
- можно: провайдерные адаптеры, retry, rate limits;
|
- webhook отвечает `200` только после успешной постановки задачи в Hatchet;
|
||||||
- нельзя: резолвинг бизнес-правил и маршрутизации диалога.
|
- при недоступности сервисов задача ретраится Hatchet;
|
||||||
|
- inbound обработка идемпотентна через `idempotencyKey` и provider identifiers в `backend`.
|
||||||
## Универсальный протокол событий
|
- календарный sync использует advisory-lock в `backend`, поэтому параллельные cron-run безопасны.
|
||||||
|
|
||||||
Внутренний контракт входящих событий: `docs/contracts/omni-inbound-envelope.v1.json`.
|
|
||||||
|
|
||||||
Обязательные поля:
|
|
||||||
|
|
||||||
- `version`
|
|
||||||
- `idempotencyKey`
|
|
||||||
- `provider`, `channel`, `direction`
|
|
||||||
- `providerEventId`, `providerMessageId`
|
|
||||||
- `eventType`, `occurredAt`, `receivedAt`
|
|
||||||
- `payloadRaw`, `payloadNormalized`
|
|
||||||
|
|
||||||
## Идемпотентность и надежность
|
|
||||||
|
|
||||||
- `jobId` в очереди строится из `idempotencyKey`.
|
|
||||||
- Дубликаты входящих webhook событий безопасны и возвращают `200`.
|
|
||||||
- `200` от `omni_inbound` отдается только после успешного добавления в Redis/BullMQ.
|
|
||||||
- При ошибке durable enqueue `omni_inbound` возвращает `5xx`, провайдер выполняет повторную доставку.
|
|
||||||
- Базовые рабочие очереди: `receiver.flow` и `sender.flow`; технические очереди для эскалации: `receiver.retry`, `sender.retry`, `receiver.dlq`, `sender.dlq`.
|
|
||||||
|
|
||||||
## Последствия
|
## Последствия
|
||||||
|
|
||||||
Плюсы:
|
Плюсы:
|
||||||
|
- меньше скрытых связей;
|
||||||
- независимые релизы и масштабирование по ролям;
|
- изоляция доменной БД в `backend`;
|
||||||
- меньше blast radius при инцидентах;
|
- единая точка ретраев/оркестрации (Hatchet).
|
||||||
- проще подключать новые каналы поверх общего контракта.
|
|
||||||
|
|
||||||
Минусы:
|
Минусы:
|
||||||
|
- выше требования к стабильности GraphQL-контрактов между сервисами;
|
||||||
- больше инфраструктурных компонентов (очереди, мониторинг, трассировка);
|
- нужна наблюдаемость по цепочкам `telegram_backend -> hatchet -> telegram_worker -> backend` и `hatchet -> backend_worker -> backend`.
|
||||||
- требуется дисциплина по контрактам между сервисами.
|
|
||||||
|
|
||||||
## План внедрения
|
|
||||||
|
|
||||||
1. Вводим `omni_inbound` как отдельный сервис для Telegram Business.
|
|
||||||
2. Потребление `receiver.flow` реализуем в `omni_chat`.
|
|
||||||
3. Текущее исходящее API оставляем за `omni_outbound`.
|
|
||||||
4. После стабилизации выносим оставшиеся omni endpoint'ы из `frontend` в `omni_chat`/`omni_inbound`.
|
|
||||||
|
|||||||
@@ -3,18 +3,17 @@
|
|||||||
## Single source of truth
|
## Single source of truth
|
||||||
|
|
||||||
- Canonical Prisma schema: `frontend/prisma/schema.prisma`.
|
- Canonical Prisma schema: `frontend/prisma/schema.prisma`.
|
||||||
- Service copies:
|
- Service copy:
|
||||||
- `omni_chat/prisma/schema.prisma`
|
- `backend/prisma/schema.prisma`
|
||||||
- `omni_outbound/prisma/schema.prisma`
|
|
||||||
|
|
||||||
## Update flow
|
## Update flow
|
||||||
|
|
||||||
1. Edit only `frontend/prisma/schema.prisma`.
|
1. Edit only `frontend/prisma/schema.prisma`.
|
||||||
2. Run `./scripts/prisma-sync.sh`.
|
2. Run `./scripts/prisma-sync.sh`.
|
||||||
3. Run `./scripts/prisma-check.sh`.
|
3. Run `./scripts/prisma-check.sh`.
|
||||||
4. Commit changed schema copies.
|
4. Commit changed schema copy.
|
||||||
|
|
||||||
## Rollout policy
|
## Rollout policy
|
||||||
|
|
||||||
- Schema rollout (`prisma db push` / migrations) is allowed only in `frontend`.
|
- Schema rollout (`prisma db push` / migrations) is allowed only in `frontend`.
|
||||||
- `omni_chat` and `omni_outbound` must use generated Prisma client only.
|
- `backend` must use generated Prisma client only.
|
||||||
|
|||||||
1
frontend
Submodule
1
frontend
Submodule
Submodule frontend added at de7155bad4
@@ -1,9 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
node_modules
|
|
||||||
.nuxt
|
|
||||||
.output
|
|
||||||
.data
|
|
||||||
npm-debug.log*
|
|
||||||
dist
|
|
||||||
coverage
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clientsflow?schema=public"
|
|
||||||
REDIS_URL="redis://localhost:6379"
|
|
||||||
|
|
||||||
# Agent (LangGraph + OpenRouter)
|
|
||||||
OPENROUTER_API_KEY=""
|
|
||||||
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
|
||||||
OPENROUTER_MODEL="openai/gpt-4o-mini"
|
|
||||||
# Optional headers for OpenRouter ranking/analytics
|
|
||||||
OPENROUTER_HTTP_REFERER=""
|
|
||||||
OPENROUTER_X_TITLE="clientsflow"
|
|
||||||
# Enable reasoning payload for models that support it: 1 or 0
|
|
||||||
OPENROUTER_REASONING_ENABLED="0"
|
|
||||||
|
|
||||||
# Langfuse local tracing (optional)
|
|
||||||
LANGFUSE_ENABLED="true"
|
|
||||||
LANGFUSE_BASE_URL="http://localhost:3001"
|
|
||||||
LANGFUSE_PUBLIC_KEY="pk-lf-local"
|
|
||||||
LANGFUSE_SECRET_KEY="sk-lf-local"
|
|
||||||
|
|
||||||
# Optional fallback (OpenAI-compatible)
|
|
||||||
OPENAI_API_KEY=""
|
|
||||||
OPENAI_MODEL="gpt-4o-mini"
|
|
||||||
# "langgraph" (default) or "rule"
|
|
||||||
CF_AGENT_MODE="langgraph"
|
|
||||||
CF_WHISPER_MODEL="Xenova/whisper-small"
|
|
||||||
CF_WHISPER_LANGUAGE="ru"
|
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN=""
|
|
||||||
TELEGRAM_WEBHOOK_SECRET=""
|
|
||||||
TELEGRAM_DEFAULT_TEAM_ID="demo-team"
|
|
||||||
|
|
||||||
# Frontend GraphQL endpoint for Apollo client runtime
|
|
||||||
GRAPHQL_HTTP_ENDPOINT="http://localhost:3000/api/graphql"
|
|
||||||
# Remote GraphQL schema URL for codegen (used by `pnpm codegen` / `npm run codegen`)
|
|
||||||
GRAPHQL_SCHEMA_URL=""
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { StorybookConfig } from "@storybook/vue3-vite";
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: ["../components/**/*.stories.@(ts|tsx)"],
|
|
||||||
addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"],
|
|
||||||
framework: {
|
|
||||||
name: "@storybook/vue3-vite",
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { Preview } from "@storybook/vue3-vite";
|
|
||||||
import "../assets/css/main.css";
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default preview;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
FROM node:22-bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
|
|
||||||
RUN apt-get update -y \
|
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --ignore-scripts --legacy-peer-deps
|
|
||||||
RUN set -eux; \
|
|
||||||
arch="$(dpkg --print-architecture)"; \
|
|
||||||
if [ "$arch" = "amd64" ]; then \
|
|
||||||
npm rebuild sharp --platform=linux --arch=x64 || npm install --no-save sharp --platform=linux --arch=x64; \
|
|
||||||
elif [ "$arch" = "arm64" ]; then \
|
|
||||||
npm rebuild sharp --platform=linux --arch=arm64 || npm install --no-save sharp --platform=linux --arch=arm64; \
|
|
||||||
else \
|
|
||||||
npm rebuild sharp || true; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build server bundle at image build time.
|
|
||||||
RUN npm run postinstall && npm run build
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NITRO_HOST=0.0.0.0
|
|
||||||
ENV NITRO_PORT=3000
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Keep schema in sync, then start Nitro production server.
|
|
||||||
CMD ["bash", "-lc", "npx prisma db push && node .output/server/index.mjs"]
|
|
||||||
6970
frontend/app.vue
6970
frontend/app.vue
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "daisyui";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-accent: #1e6bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 100% 0%, rgba(30, 107, 255, 0.08), transparent 40%),
|
|
||||||
radial-gradient(circle at 0% 100%, rgba(30, 107, 255, 0.08), transparent 40%),
|
|
||||||
#f5f7fb;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { CodegenConfig } from "@graphql-codegen/cli";
|
|
||||||
|
|
||||||
const schemaUrl = process.env.GRAPHQL_SCHEMA_URL || process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql";
|
|
||||||
|
|
||||||
const config: CodegenConfig = {
|
|
||||||
schema: schemaUrl,
|
|
||||||
documents: ["graphql/operations/**/*.graphql"],
|
|
||||||
generates: {
|
|
||||||
"composables/graphql/generated.ts": {
|
|
||||||
plugins: [
|
|
||||||
"typescript",
|
|
||||||
"typescript-operations",
|
|
||||||
"typed-document-node",
|
|
||||||
"typescript-vue-apollo",
|
|
||||||
],
|
|
||||||
config: {
|
|
||||||
withCompositionFunctions: true,
|
|
||||||
vueCompositionApiImportFrom: "vue",
|
|
||||||
dedupeFragments: true,
|
|
||||||
namingConvention: "keep",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ignoreNoDocuments: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/vue3";
|
|
||||||
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
|
|
||||||
|
|
||||||
const meta: Meta<typeof ContactCollaborativeEditor> = {
|
|
||||||
title: "Components/ContactCollaborativeEditor",
|
|
||||||
component: ContactCollaborativeEditor,
|
|
||||||
args: {
|
|
||||||
modelValue: "<p>Client summary draft...</p>",
|
|
||||||
room: "storybook-contact-editor-room",
|
|
||||||
placeholder: "Type here...",
|
|
||||||
plain: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof ContactCollaborativeEditor>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
||||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
|
||||||
import Collaboration from "@tiptap/extension-collaboration";
|
|
||||||
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
|
||||||
import * as Y from "yjs";
|
|
||||||
import { WebrtcProvider } from "y-webrtc";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: string;
|
|
||||||
room: string;
|
|
||||||
placeholder?: string;
|
|
||||||
plain?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: "update:modelValue", value: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const ydoc = new Y.Doc();
|
|
||||||
const provider = new WebrtcProvider(props.room, ydoc);
|
|
||||||
const isBootstrapped = ref(false);
|
|
||||||
const awarenessVersion = ref(0);
|
|
||||||
|
|
||||||
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
|
|
||||||
const currentUser = {
|
|
||||||
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
|
|
||||||
color: userPalette[Math.floor(Math.random() * userPalette.length)],
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInitialContent(value: string) {
|
|
||||||
const input = value.trim();
|
|
||||||
if (!input) return "<p></p>";
|
|
||||||
if (input.includes("<") && input.includes(">")) return value;
|
|
||||||
|
|
||||||
const blocks = value
|
|
||||||
.replaceAll("\r\n", "\n")
|
|
||||||
.split(/\n\n+/)
|
|
||||||
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
|
|
||||||
|
|
||||||
return blocks.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit.configure({
|
|
||||||
history: false,
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: props.placeholder ?? "Type here...",
|
|
||||||
includeChildren: true,
|
|
||||||
}),
|
|
||||||
Collaboration.configure({
|
|
||||||
document: ydoc,
|
|
||||||
field: "contact",
|
|
||||||
}),
|
|
||||||
CollaborationCursor.configure({
|
|
||||||
provider,
|
|
||||||
user: currentUser,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
autofocus: true,
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: "contact-editor-content",
|
|
||||||
spellcheck: "true",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onCreate: ({ editor: instance }) => {
|
|
||||||
if (instance.isEmpty) {
|
|
||||||
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
|
|
||||||
}
|
|
||||||
isBootstrapped.value = true;
|
|
||||||
},
|
|
||||||
onUpdate: ({ editor: instance }) => {
|
|
||||||
emit("update:modelValue", instance.getHTML());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(incoming) => {
|
|
||||||
const instance = editor.value;
|
|
||||||
if (!instance || !isBootstrapped.value) return;
|
|
||||||
|
|
||||||
const current = instance.getHTML();
|
|
||||||
if (incoming === current || !incoming.trim()) return;
|
|
||||||
|
|
||||||
if (instance.isEmpty) {
|
|
||||||
instance.commands.setContent(normalizeInitialContent(incoming), false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const peerCount = computed(() => {
|
|
||||||
awarenessVersion.value;
|
|
||||||
const states = Array.from(provider.awareness.getStates().values());
|
|
||||||
return states.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAwarenessChange = () => {
|
|
||||||
awarenessVersion.value += 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
provider.awareness.on("change", onAwarenessChange);
|
|
||||||
|
|
||||||
function runCommand(action: () => void) {
|
|
||||||
const instance = editor.value;
|
|
||||||
if (!instance) return;
|
|
||||||
action();
|
|
||||||
instance.commands.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
provider.awareness.off("change", onAwarenessChange);
|
|
||||||
editor.value?.destroy();
|
|
||||||
provider.destroy();
|
|
||||||
ydoc.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
|
|
||||||
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
|
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
|
||||||
<button
|
|
||||||
class="btn btn-xs"
|
|
||||||
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-xs"
|
|
||||||
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
|
|
||||||
>
|
|
||||||
I
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-xs"
|
|
||||||
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
|
|
||||||
>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-xs"
|
|
||||||
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
|
|
||||||
>
|
|
||||||
H2
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-xs"
|
|
||||||
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
|
|
||||||
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
|
|
||||||
>
|
|
||||||
Quote
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
|
|
||||||
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.contact-editor :deep(.ProseMirror) {
|
|
||||||
min-height: 390px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
outline: none;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: rgba(17, 24, 39, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror p) {
|
|
||||||
margin: 0.45rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror h1),
|
|
||||||
.contact-editor :deep(.ProseMirror h2),
|
|
||||||
.contact-editor :deep(.ProseMirror h3) {
|
|
||||||
margin: 0.75rem 0 0.45rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror ul),
|
|
||||||
.contact-editor :deep(.ProseMirror ol) {
|
|
||||||
margin: 0.45rem 0;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror blockquote) {
|
|
||||||
margin: 0.6rem 0;
|
|
||||||
border-left: 3px solid rgba(30, 107, 255, 0.5);
|
|
||||||
padding-left: 0.75rem;
|
|
||||||
color: rgba(55, 65, 81, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
|
|
||||||
margin-left: -1px;
|
|
||||||
margin-right: -1px;
|
|
||||||
border-left: 1px solid currentColor;
|
|
||||||
border-right: 1px solid currentColor;
|
|
||||||
pointer-events: none;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
|
|
||||||
position: absolute;
|
|
||||||
top: -1.35em;
|
|
||||||
left: -1px;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
export const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
|
|
||||||
|
|
||||||
export function buildContactDocumentScope(contactId: string, contactName: string) {
|
|
||||||
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseContactDocumentScope(scope: string) {
|
|
||||||
const raw = String(scope ?? "").trim();
|
|
||||||
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
|
|
||||||
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
|
|
||||||
const [idRaw, ...nameParts] = payload.split(":");
|
|
||||||
const contactId = decodeURIComponent(idRaw ?? "").trim();
|
|
||||||
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
|
|
||||||
if (!contactId) return null;
|
|
||||||
return {
|
|
||||||
contactId,
|
|
||||||
contactName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDocumentScope(scope: string) {
|
|
||||||
const linked = parseContactDocumentScope(scope);
|
|
||||||
if (!linked) return scope;
|
|
||||||
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDocumentLinkedToContact(
|
|
||||||
scope: string,
|
|
||||||
contact: { id: string; name: string } | null | undefined,
|
|
||||||
) {
|
|
||||||
if (!contact) return false;
|
|
||||||
const linked = parseContactDocumentScope(scope);
|
|
||||||
if (!linked) return false;
|
|
||||||
if (linked.contactId) return linked.contactId === contact.id;
|
|
||||||
return Boolean(linked.contactName && linked.contactName === contact.name);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
|
||||||
|
|
||||||
export default withNuxt(
|
|
||||||
// Your custom configs here
|
|
||||||
)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
|
|
||||||
archiveCalendarEvent(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
start
|
|
||||||
end
|
|
||||||
contact
|
|
||||||
note
|
|
||||||
isArchived
|
|
||||||
createdAt
|
|
||||||
archiveNote
|
|
||||||
archivedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation ArchiveChatConversationMutation($id: ID!) {
|
|
||||||
archiveChatConversation(id: $id) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
query ChatConversationsQuery {
|
|
||||||
chatConversations {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
lastMessageAt
|
|
||||||
lastMessageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
query ChatMessagesQuery {
|
|
||||||
chatMessages {
|
|
||||||
id
|
|
||||||
role
|
|
||||||
text
|
|
||||||
messageKind
|
|
||||||
requestId
|
|
||||||
eventType
|
|
||||||
phase
|
|
||||||
transient
|
|
||||||
thinking
|
|
||||||
tools
|
|
||||||
toolRuns {
|
|
||||||
name
|
|
||||||
status
|
|
||||||
input
|
|
||||||
output
|
|
||||||
at
|
|
||||||
}
|
|
||||||
changeSetId
|
|
||||||
changeStatus
|
|
||||||
changeSummary
|
|
||||||
changeItems {
|
|
||||||
id
|
|
||||||
entity
|
|
||||||
entityId
|
|
||||||
action
|
|
||||||
title
|
|
||||||
before
|
|
||||||
after
|
|
||||||
rolledBack
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation ConfirmLatestChangeSetMutation {
|
|
||||||
confirmLatestChangeSet {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
|
|
||||||
createCalendarEvent(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
start
|
|
||||||
end
|
|
||||||
contact
|
|
||||||
note
|
|
||||||
isArchived
|
|
||||||
createdAt
|
|
||||||
archiveNote
|
|
||||||
archivedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
mutation CreateChatConversationMutation($title: String) {
|
|
||||||
createChatConversation(title: $title) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
lastMessageAt
|
|
||||||
lastMessageText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
|
|
||||||
createCommunication(input: $input) {
|
|
||||||
ok
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
|
|
||||||
createWorkspaceDocument(input: $input) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
type
|
|
||||||
owner
|
|
||||||
scope
|
|
||||||
updatedAt
|
|
||||||
summary
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
query DashboardQuery {
|
|
||||||
dashboard {
|
|
||||||
contacts {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
avatar
|
|
||||||
company
|
|
||||||
country
|
|
||||||
location
|
|
||||||
channels
|
|
||||||
lastContactAt
|
|
||||||
description
|
|
||||||
}
|
|
||||||
communications {
|
|
||||||
id
|
|
||||||
at
|
|
||||||
contactId
|
|
||||||
contact
|
|
||||||
channel
|
|
||||||
kind
|
|
||||||
direction
|
|
||||||
text
|
|
||||||
audioUrl
|
|
||||||
duration
|
|
||||||
transcript
|
|
||||||
deliveryStatus
|
|
||||||
}
|
|
||||||
calendar {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
start
|
|
||||||
end
|
|
||||||
contact
|
|
||||||
note
|
|
||||||
isArchived
|
|
||||||
createdAt
|
|
||||||
archiveNote
|
|
||||||
archivedAt
|
|
||||||
}
|
|
||||||
deals {
|
|
||||||
id
|
|
||||||
contact
|
|
||||||
title
|
|
||||||
company
|
|
||||||
stage
|
|
||||||
amount
|
|
||||||
nextStep
|
|
||||||
summary
|
|
||||||
currentStepId
|
|
||||||
steps {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
description
|
|
||||||
status
|
|
||||||
dueAt
|
|
||||||
order
|
|
||||||
completedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
feed {
|
|
||||||
id
|
|
||||||
at
|
|
||||||
contact
|
|
||||||
text
|
|
||||||
proposal {
|
|
||||||
title
|
|
||||||
details
|
|
||||||
key
|
|
||||||
}
|
|
||||||
decision
|
|
||||||
decisionNote
|
|
||||||
}
|
|
||||||
pins {
|
|
||||||
id
|
|
||||||
contact
|
|
||||||
text
|
|
||||||
}
|
|
||||||
documents {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
type
|
|
||||||
owner
|
|
||||||
scope
|
|
||||||
updatedAt
|
|
||||||
summary
|
|
||||||
body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LogPilotNoteMutation($text: String!) {
|
|
||||||
logPilotNote(text: $text) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LoginMutation($phone: String!, $password: String!) {
|
|
||||||
login(phone: $phone, password: $password) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation LogoutMutation {
|
|
||||||
logout {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
query MeQuery {
|
|
||||||
me {
|
|
||||||
user {
|
|
||||||
id
|
|
||||||
phone
|
|
||||||
name
|
|
||||||
}
|
|
||||||
team {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
conversation {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation RollbackChangeSetItemsMutation($changeSetId: ID!, $itemIds: [ID!]!) {
|
|
||||||
rollbackChangeSetItems(changeSetId: $changeSetId, itemIds: $itemIds) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation RollbackLatestChangeSetMutation {
|
|
||||||
rollbackLatestChangeSet {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation SelectChatConversationMutation($id: ID!) {
|
|
||||||
selectChatConversation(id: $id) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
mutation SendPilotMessageMutation($text: String!) {
|
|
||||||
sendPilotMessage(text: $text) {
|
|
||||||
ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation ToggleContactPinMutation($contact: String!, $text: String!) {
|
|
||||||
toggleContactPin(contact: $contact, text: $text) {
|
|
||||||
ok
|
|
||||||
pinned
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation UpdateCommunicationTranscriptMutation($id: ID!, $transcript: [String!]!) {
|
|
||||||
updateCommunicationTranscript(id: $id, transcript: $transcript) {
|
|
||||||
ok
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
|
|
||||||
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
|
|
||||||
ok
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
compatibilityDate: "2025-07-15",
|
|
||||||
devtools: { enabled: true },
|
|
||||||
css: ["~/assets/css/main.css"],
|
|
||||||
nitro: {
|
|
||||||
experimental: {
|
|
||||||
websocket: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
vite: {
|
|
||||||
plugins: [tailwindcss() as any],
|
|
||||||
},
|
|
||||||
|
|
||||||
modules: ["@nuxt/eslint", "@nuxtjs/apollo"],
|
|
||||||
|
|
||||||
runtimeConfig: {
|
|
||||||
public: {
|
|
||||||
graphqlHttpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
apollo: {
|
|
||||||
clients: {
|
|
||||||
default: {
|
|
||||||
httpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
|
|
||||||
connectToDevTools: process.dev,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
23266
frontend/package-lock.json
generated
23266
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crm-frontend",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxt build",
|
|
||||||
"db:generate": "prisma generate",
|
|
||||||
"db:migrate": "prisma migrate dev",
|
|
||||||
"db:push": "prisma db push",
|
|
||||||
"db:seed": "node prisma/seed.mjs",
|
|
||||||
"dataset:export": "node scripts/export-dataset.mjs",
|
|
||||||
"dev": "nuxt dev",
|
|
||||||
"generate": "nuxt generate",
|
|
||||||
"postinstall": "nuxt prepare && prisma generate",
|
|
||||||
"preview": "nuxt preview",
|
|
||||||
"typecheck": "nuxt typecheck",
|
|
||||||
"codegen": "graphql-codegen --config codegen.ts",
|
|
||||||
"storybook": "storybook dev -p 6006",
|
|
||||||
"storybook:build": "storybook build"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@ai-sdk/vue": "^3.0.91",
|
|
||||||
"@apollo/client": "^3.14.0",
|
|
||||||
"@langchain/core": "^0.3.77",
|
|
||||||
"@langchain/langgraph": "^0.2.74",
|
|
||||||
"@langchain/openai": "^0.6.9",
|
|
||||||
"@nuxt/eslint": "^1.15.1",
|
|
||||||
"@nuxtjs/apollo": "^5.0.0-alpha.15",
|
|
||||||
"@prisma/client": "^6.16.1",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"@tiptap/extension-collaboration": "^2.27.2",
|
|
||||||
"@tiptap/extension-collaboration-cursor": "^2.27.2",
|
|
||||||
"@tiptap/extension-placeholder": "^2.27.2",
|
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
|
||||||
"@tiptap/vue-3": "^2.27.2",
|
|
||||||
"@vue/apollo-composable": "^4.2.2",
|
|
||||||
"@xenova/transformers": "^2.17.2",
|
|
||||||
"ai": "^6.0.91",
|
|
||||||
"bullmq": "^5.58.2",
|
|
||||||
"daisyui": "^5.5.18",
|
|
||||||
"graphql": "^16.12.0",
|
|
||||||
"graphql-tag": "^2.12.6",
|
|
||||||
"ioredis": "^5.7.0",
|
|
||||||
"langfuse": "^3.38.6",
|
|
||||||
"langsmith": "^0.5.4",
|
|
||||||
"nuxt": "^4.3.1",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"vue": "^3.5.27",
|
|
||||||
"wavesurfer.js": "^7.12.1",
|
|
||||||
"y-prosemirror": "^1.3.7",
|
|
||||||
"y-webrtc": "^10.3.0",
|
|
||||||
"yjs": "^13.6.29",
|
|
||||||
"zod": "^4.1.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@graphql-codegen/cli": "^6.1.1",
|
|
||||||
"@graphql-codegen/typed-document-node": "^6.1.6",
|
|
||||||
"@graphql-codegen/typescript": "^5.0.8",
|
|
||||||
"@graphql-codegen/typescript-operations": "^5.0.8",
|
|
||||||
"@graphql-codegen/typescript-vue-apollo": "^4.1.2",
|
|
||||||
"@storybook/addon-essentials": "^8.6.17",
|
|
||||||
"@storybook/addon-interactions": "^8.6.17",
|
|
||||||
"@storybook/test": "^8.6.17",
|
|
||||||
"@storybook/vue3-vite": "^8.6.17",
|
|
||||||
"prisma": "^6.16.1",
|
|
||||||
"storybook": "^8.6.17",
|
|
||||||
"tsx": "^4.20.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TeamRole {
|
|
||||||
OWNER
|
|
||||||
MEMBER
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageDirection {
|
|
||||||
IN
|
|
||||||
OUT
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageChannel {
|
|
||||||
TELEGRAM
|
|
||||||
WHATSAPP
|
|
||||||
INSTAGRAM
|
|
||||||
PHONE
|
|
||||||
EMAIL
|
|
||||||
INTERNAL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ContactMessageKind {
|
|
||||||
MESSAGE
|
|
||||||
CALL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ChatRole {
|
|
||||||
USER
|
|
||||||
ASSISTANT
|
|
||||||
SYSTEM
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OmniMessageStatus {
|
|
||||||
PENDING
|
|
||||||
SENT
|
|
||||||
FAILED
|
|
||||||
DELIVERED
|
|
||||||
READ
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FeedCardDecision {
|
|
||||||
PENDING
|
|
||||||
ACCEPTED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WorkspaceDocumentType {
|
|
||||||
Regulation
|
|
||||||
Playbook
|
|
||||||
Policy
|
|
||||||
Template
|
|
||||||
}
|
|
||||||
|
|
||||||
model Team {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
members TeamMember[]
|
|
||||||
contacts Contact[]
|
|
||||||
calendarEvents CalendarEvent[]
|
|
||||||
deals Deal[]
|
|
||||||
conversations ChatConversation[]
|
|
||||||
chatMessages ChatMessage[]
|
|
||||||
|
|
||||||
omniThreads OmniThread[]
|
|
||||||
omniMessages OmniMessage[]
|
|
||||||
omniIdentities OmniContactIdentity[]
|
|
||||||
telegramBusinessConnections TelegramBusinessConnection[]
|
|
||||||
|
|
||||||
feedCards FeedCard[]
|
|
||||||
contactPins ContactPin[]
|
|
||||||
documents WorkspaceDocument[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
phone String @unique
|
|
||||||
passwordHash String
|
|
||||||
email String? @unique
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
memberships TeamMember[]
|
|
||||||
conversations ChatConversation[] @relation("ConversationCreator")
|
|
||||||
chatMessages ChatMessage[] @relation("ChatAuthor")
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamMember {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
userId String
|
|
||||||
role TeamRole @default(MEMBER)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, userId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Contact {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
name String
|
|
||||||
company String?
|
|
||||||
country String?
|
|
||||||
location String?
|
|
||||||
avatarUrl String?
|
|
||||||
email String?
|
|
||||||
phone String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
note ContactNote?
|
|
||||||
messages ContactMessage[]
|
|
||||||
events CalendarEvent[]
|
|
||||||
deals Deal[]
|
|
||||||
feedCards FeedCard[]
|
|
||||||
pins ContactPin[]
|
|
||||||
|
|
||||||
omniThreads OmniThread[]
|
|
||||||
omniMessages OmniMessage[]
|
|
||||||
omniIdentities OmniContactIdentity[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactNote {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
contactId String @unique
|
|
||||||
content String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
contactId String
|
|
||||||
kind ContactMessageKind @default(MESSAGE)
|
|
||||||
direction MessageDirection
|
|
||||||
channel MessageChannel
|
|
||||||
content String
|
|
||||||
audioUrl String?
|
|
||||||
durationSec Int?
|
|
||||||
transcriptJson Json?
|
|
||||||
occurredAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([contactId, occurredAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniContactIdentity {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
channel MessageChannel
|
|
||||||
externalId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, channel, externalId])
|
|
||||||
@@index([contactId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniThread {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
channel MessageChannel
|
|
||||||
externalChatId String
|
|
||||||
businessConnectionId String?
|
|
||||||
title String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
messages OmniMessage[]
|
|
||||||
|
|
||||||
@@unique([teamId, channel, externalChatId, businessConnectionId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
threadId String
|
|
||||||
direction MessageDirection
|
|
||||||
channel MessageChannel
|
|
||||||
status OmniMessageStatus @default(PENDING)
|
|
||||||
text String
|
|
||||||
providerMessageId String?
|
|
||||||
providerUpdateId String?
|
|
||||||
rawJson Json?
|
|
||||||
occurredAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([threadId, providerMessageId])
|
|
||||||
@@index([teamId, occurredAt])
|
|
||||||
@@index([threadId, occurredAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model TelegramBusinessConnection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
businessConnectionId String
|
|
||||||
isEnabled Boolean?
|
|
||||||
canReply Boolean?
|
|
||||||
rawJson Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, businessConnectionId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CalendarEvent {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String?
|
|
||||||
title String
|
|
||||||
startsAt DateTime
|
|
||||||
endsAt DateTime?
|
|
||||||
note String?
|
|
||||||
isArchived Boolean @default(false)
|
|
||||||
archiveNote String?
|
|
||||||
archivedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([startsAt])
|
|
||||||
@@index([contactId, startsAt])
|
|
||||||
@@index([teamId, startsAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Deal {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
title String
|
|
||||||
stage String
|
|
||||||
amount Int?
|
|
||||||
nextStep String?
|
|
||||||
summary String?
|
|
||||||
currentStepId String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
steps DealStep[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
@@index([currentStepId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DealStep {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
dealId String
|
|
||||||
title String
|
|
||||||
description String?
|
|
||||||
status String @default("todo")
|
|
||||||
dueAt DateTime?
|
|
||||||
order Int @default(0)
|
|
||||||
completedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([dealId, order])
|
|
||||||
@@index([status, dueAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatConversation {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
createdByUserId String
|
|
||||||
title String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
messages ChatMessage[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([createdByUserId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
conversationId String
|
|
||||||
authorUserId String?
|
|
||||||
role ChatRole
|
|
||||||
text String
|
|
||||||
planJson Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
||||||
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([teamId, createdAt])
|
|
||||||
@@index([conversationId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model FeedCard {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String?
|
|
||||||
happenedAt DateTime
|
|
||||||
text String
|
|
||||||
proposalJson Json
|
|
||||||
decision FeedCardDecision @default(PENDING)
|
|
||||||
decisionNote String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([teamId, happenedAt])
|
|
||||||
@@index([contactId, happenedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactPin {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
text String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model WorkspaceDocument {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
title String
|
|
||||||
type WorkspaceDocumentType
|
|
||||||
owner String
|
|
||||||
scope String
|
|
||||||
summary String
|
|
||||||
body String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { randomBytes, scryptSync } from "node:crypto";
|
|
||||||
|
|
||||||
function loadEnvFromDotEnv() {
|
|
||||||
const p = path.resolve(process.cwd(), ".env");
|
|
||||||
if (!fs.existsSync(p)) return;
|
|
||||||
const raw = fs.readFileSync(p, "utf8");
|
|
||||||
for (const line of raw.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
const idx = trimmed.indexOf("=");
|
|
||||||
if (idx === -1) continue;
|
|
||||||
const key = trimmed.slice(0, idx).trim();
|
|
||||||
let val = trimmed.slice(idx + 1).trim();
|
|
||||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
||||||
val = val.slice(1, -1);
|
|
||||||
}
|
|
||||||
if (!key) continue;
|
|
||||||
if (key === "DATABASE_URL") {
|
|
||||||
if (!process.env[key]) process.env[key] = val;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!process.env[key]) process.env[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvFromDotEnv();
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const LOGIN_PHONE = "+15550000001";
|
|
||||||
const LOGIN_PASSWORD = "ConnectFlow#2026";
|
|
||||||
const LOGIN_NAME = "Владелец Connect";
|
|
||||||
const REF_DATE_ISO = "2026-02-20T12:00:00.000Z";
|
|
||||||
|
|
||||||
const SCRYPT_KEY_LENGTH = 64;
|
|
||||||
|
|
||||||
function hashPassword(password) {
|
|
||||||
const salt = randomBytes(16).toString("base64url");
|
|
||||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
|
||||||
return `scrypt$${salt}$${digest}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function atOffset(days, hour, minute) {
|
|
||||||
const d = new Date(REF_DATE_ISO);
|
|
||||||
d.setDate(d.getDate() + days);
|
|
||||||
d.setHours(hour, minute, 0, 0);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function plusMinutes(date, minutes) {
|
|
||||||
const d = new Date(date);
|
|
||||||
d.setMinutes(d.getMinutes() + minutes);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOdooAiContacts(teamId) {
|
|
||||||
const prospects = [
|
|
||||||
{ name: "Оливия Рид", company: "РитейлНова", country: "США", location: "Нью-Йорк", email: "olivia.reed@retailnova.com", phone: "+1 555 120 0101" },
|
|
||||||
{ name: "Даниэль Ким", company: "ФорджПик Производство", country: "США", location: "Чикаго", email: "daniel.kim@forgepeak.com", phone: "+1 555 120 0102" },
|
|
||||||
{ name: "Марта Алонсо", company: "Иберия Фудс Групп", country: "Испания", location: "Барселона", email: "marta.alonso@iberiafoods.es", phone: "+34 91 555 0103" },
|
|
||||||
{ name: "Юсеф Хаддад", company: "ГалфТрейд Дистрибуция", country: "ОАЭ", location: "Дубай", email: "youssef.haddad@gulftrade.ae", phone: "+971 4 555 0104" },
|
|
||||||
{ name: "Эмма Коллинз", company: "НортБридж Логистика", country: "Великобритания", location: "Лондон", email: "emma.collins@northbridge.co.uk", phone: "+44 20 5550 0105" },
|
|
||||||
{ name: "Ноа Фишер", company: "Бергман Автозапчасти", country: "Германия", location: "Мюнхен", email: "noah.fischer@bergmann-auto.de", phone: "+49 89 5550 0106" },
|
|
||||||
{ name: "Ава Чой", company: "Пасифик МедТех Сапплай", country: "Сингапур", location: "Сингапур", email: "ava.choi@pacificmedtech.sg", phone: "+65 6555 0107" },
|
|
||||||
{ name: "Лиам Дюбуа", company: "ГексаКоммерс", country: "Франция", location: "Париж", email: "liam.dubois@hexacommerce.fr", phone: "+33 1 55 50 0108" },
|
|
||||||
{ name: "Майя Шах", company: "Зенит Консьюмер Брендс", country: "Канада", location: "Торонто", email: "maya.shah@zenithbrands.ca", phone: "+1 416 555 0109" },
|
|
||||||
{ name: "Арман Петросян", company: "Арарат Электроникс", country: "Армения", location: "Ереван", email: "arman.petrosyan@ararat-electronics.am", phone: "+374 10 555110" },
|
|
||||||
{ name: "София Мартинес", company: "Санлайн Товары для дома", country: "США", location: "Остин", email: "sophia.martinez@sunlinehg.com", phone: "+1 555 120 0111" },
|
|
||||||
{ name: "Лео Новак", company: "ЦентралБилд Материалы", country: "Германия", location: "Берлин", email: "leo.novak@centralbuild.de", phone: "+49 30 5550 0112" },
|
|
||||||
{ name: "Айла Грант", company: "БлюХарбор Фарма", country: "Великобритания", location: "Манчестер", email: "isla.grant@blueharbor.co.uk", phone: "+44 161 555 0113" },
|
|
||||||
{ name: "Матео Росси", company: "Милано Фэшн Хаус", country: "Италия", location: "Милан", email: "mateo.rossi@milanofh.it", phone: "+39 02 5550 0114" },
|
|
||||||
{ name: "Нина Волкова", company: "Полар АгриТех", country: "Казахстан", location: "Алматы", email: "nina.volkova@polaragri.kz", phone: "+7 727 555 0115" },
|
|
||||||
{ name: "Итан Пак", company: "Вертекс Компонентс", country: "Южная Корея", location: "Сеул", email: "ethan.park@vertexcomponents.kr", phone: "+82 2 555 0116" },
|
|
||||||
{ name: "Зара Хан", company: "Кресент Ритейл Чейн", country: "ОАЭ", location: "Абу-Даби", email: "zara.khan@crescentretail.ae", phone: "+971 2 555 0117" },
|
|
||||||
{ name: "Уго Силва", company: "Лузо Индастриал Системс", country: "Португалия", location: "Лиссабон", email: "hugo.silva@lusois.pt", phone: "+351 21 555 0118" },
|
|
||||||
{ name: "Хлоя Бернар", company: "Сантекс Сеть Клиник", country: "Франция", location: "Лион", email: "chloe.bernard@santex.fr", phone: "+33 4 55 50 0119" },
|
|
||||||
{ name: "Джеймс Уокер", company: "Метро Оптовая Группа", country: "США", location: "Лос-Анджелес", email: "james.walker@metrowholesale.com", phone: "+1 555 120 0120" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return prospects.map((p, idx) => {
|
|
||||||
const female = idx % 2 === 0;
|
|
||||||
const picIdx = (idx % 70) + 1;
|
|
||||||
return {
|
|
||||||
teamId,
|
|
||||||
name: p.name,
|
|
||||||
company: p.company,
|
|
||||||
country: p.country,
|
|
||||||
location: p.location,
|
|
||||||
avatarUrl: `https://randomuser.me/api/portraits/${female ? "women" : "men"}/${picIdx}.jpg`,
|
|
||||||
email: p.email,
|
|
||||||
phone: p.phone,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const passwordHash = hashPassword(LOGIN_PASSWORD);
|
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { id: "demo-user" },
|
|
||||||
update: { phone: LOGIN_PHONE, passwordHash, name: LOGIN_NAME, email: "owner@clientsflow.local" },
|
|
||||||
create: {
|
|
||||||
id: "demo-user",
|
|
||||||
phone: LOGIN_PHONE,
|
|
||||||
passwordHash,
|
|
||||||
name: LOGIN_NAME,
|
|
||||||
email: "owner@clientsflow.local",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const team = await prisma.team.upsert({
|
|
||||||
where: { id: "demo-team" },
|
|
||||||
update: { name: "Connect Рабочее пространство" },
|
|
||||||
create: { id: "demo-team", name: "Connect Рабочее пространство" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.teamMember.upsert({
|
|
||||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
|
||||||
update: { role: "OWNER" },
|
|
||||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversation = await prisma.chatConversation.upsert({
|
|
||||||
where: { id: `pilot-${team.id}` },
|
|
||||||
update: { title: "Пилот" },
|
|
||||||
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Пилот" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.feedCard.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.contactPin.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.workspaceDocument.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.deal.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.calendarEvent.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.contactMessage.deleteMany({ where: { contact: { teamId: team.id } } }),
|
|
||||||
prisma.chatMessage.deleteMany({ where: { teamId: team.id, conversationId: conversation.id } }),
|
|
||||||
prisma.omniMessage.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.omniThread.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.omniContactIdentity.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.telegramBusinessConnection.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
prisma.contact.deleteMany({ where: { teamId: team.id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const contacts = await prisma.contact.createManyAndReturn({
|
|
||||||
data: buildOdooAiContacts(team.id),
|
|
||||||
select: { id: true, name: true, company: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const integrationModules = [
|
|
||||||
"Продажи + CRM + копилот прогнозирования",
|
|
||||||
"Склад + прогноз спроса",
|
|
||||||
"Закупки + оценка рисков поставщиков",
|
|
||||||
"Бухгалтерия + AI-детекция аномалий",
|
|
||||||
"Поддержка + ассистент триажа заявок",
|
|
||||||
"Производство + AI-планирование мощностей",
|
|
||||||
];
|
|
||||||
|
|
||||||
await prisma.contactNote.createMany({
|
|
||||||
data: contacts.map((c, idx) => ({
|
|
||||||
contactId: c.id,
|
|
||||||
content:
|
|
||||||
`${c.company ?? c.name} рассматривает внедрение Odoo с AI-расширениями. ` +
|
|
||||||
`Основной контур интеграции: ${integrationModules[idx % integrationModules.length]}. ` +
|
|
||||||
`Ключевой драйвер покупки: сократить ручные операции и ускорить цикл принятия решений. ` +
|
|
||||||
`Следующая веха: провести сессию уточнения, согласовать владельцев данных и утвердить KPI пилота.`,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const channels = ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "EMAIL"];
|
|
||||||
const contactMessages = [];
|
|
||||||
for (let i = 0; i < contacts.length; i += 1) {
|
|
||||||
const contact = contacts[i];
|
|
||||||
const base = atOffset(-(i % 18), 9 + (i % 7), (i * 7) % 60);
|
|
||||||
|
|
||||||
contactMessages.push({
|
|
||||||
contactId: contact.id,
|
|
||||||
kind: "MESSAGE",
|
|
||||||
direction: "IN",
|
|
||||||
channel: channels[i % channels.length],
|
|
||||||
content: `Здравствуйте! Мы рассматриваем запуск Odoo + AI для ${contact.company}. Можем согласовать план интеграции на этой неделе?`,
|
|
||||||
occurredAt: base,
|
|
||||||
});
|
|
||||||
|
|
||||||
contactMessages.push({
|
|
||||||
contactId: contact.id,
|
|
||||||
kind: "MESSAGE",
|
|
||||||
direction: "OUT",
|
|
||||||
channel: channels[(i + 1) % channels.length],
|
|
||||||
content: "Да, предлагаю 45-минутный разбор: процессы, ограничения API и KPI пилота.",
|
|
||||||
occurredAt: plusMinutes(base, 22),
|
|
||||||
});
|
|
||||||
|
|
||||||
contactMessages.push({
|
|
||||||
contactId: contact.id,
|
|
||||||
kind: "MESSAGE",
|
|
||||||
direction: i % 3 === 0 ? "OUT" : "IN",
|
|
||||||
channel: channels[(i + 2) % channels.length],
|
|
||||||
content: "Обновление статуса: технический объём ясен; блокер — согласование бюджета и анкета по безопасности.",
|
|
||||||
occurredAt: plusMinutes(base, 65),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (i % 3 === 0) {
|
|
||||||
contactMessages.push({
|
|
||||||
contactId: contact.id,
|
|
||||||
kind: "CALL",
|
|
||||||
direction: "OUT",
|
|
||||||
channel: "PHONE",
|
|
||||||
content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии",
|
|
||||||
audioUrl: "/audio-samples/national-road-9.m4a",
|
|
||||||
durationSec: 180 + ((i * 23) % 420),
|
|
||||||
occurredAt: plusMinutes(base, 110),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await prisma.contactMessage.createMany({ data: contactMessages });
|
|
||||||
|
|
||||||
await prisma.calendarEvent.createMany({
|
|
||||||
data: contacts.flatMap((c, idx) => {
|
|
||||||
// Историческая неделя до 20 Feb 2026: все сидовые встречи завершены.
|
|
||||||
const firstStart = atOffset(-6 + (idx % 5), 10 + (idx % 6), (idx * 5) % 60);
|
|
||||||
const secondStart = atOffset(-5 + (idx % 5), 14 + (idx % 4), (idx * 3) % 60);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
contactId: c.id,
|
|
||||||
title: `Сессия уточнения: Odoo + AI с ${c.company ?? c.name}`,
|
|
||||||
startsAt: firstStart,
|
|
||||||
endsAt: plusMinutes(firstStart, 30),
|
|
||||||
note: "Подтвердить рамки интеграции, текущий стек и метрики успеха пилота.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
contactId: c.id,
|
|
||||||
title: `Архитектурный воркшоп: ${c.company ?? c.name}`,
|
|
||||||
startsAt: secondStart,
|
|
||||||
endsAt: plusMinutes(secondStart, 45),
|
|
||||||
note: "Проверить маппинг API, границы ETL и ограничения для AI-ассистента.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"];
|
|
||||||
for (const [idx, c] of contacts.entries()) {
|
|
||||||
const nextStepText =
|
|
||||||
idx % 4 === 0
|
|
||||||
? "Отправить предложение по пилоту и зафиксировать список задач интеграции."
|
|
||||||
: "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.";
|
|
||||||
|
|
||||||
const deal = await prisma.deal.create({
|
|
||||||
data: {
|
|
||||||
teamId: team.id,
|
|
||||||
contactId: c.id,
|
|
||||||
title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`,
|
|
||||||
stage: stages[idx % stages.length],
|
|
||||||
amount: 18000 + (idx % 8) * 7000,
|
|
||||||
nextStep: nextStepText,
|
|
||||||
summary:
|
|
||||||
"Потенциальная сделка на поэтапное внедрение Odoo с AI-копилотами для операций, продаж и планирования. " +
|
|
||||||
"Коммерческая модель: уточнение + пилот + тиражирование.",
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const dueBase = atOffset((idx % 5) + 1, 11 + (idx % 4), 0);
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
dealId: deal.id,
|
|
||||||
title: "Собрать уточняющие требования",
|
|
||||||
description: "Подтвердить модули Odoo, владельцев данных и критерии успеха.",
|
|
||||||
status: "done",
|
|
||||||
order: 1,
|
|
||||||
completedAt: atOffset(-2 - (idx % 3), 16, 0),
|
|
||||||
dueAt: atOffset(-1, 12, 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dealId: deal.id,
|
|
||||||
title: "Провести воркшоп по решению",
|
|
||||||
description: "Согласовать границы интеграции и план пилота.",
|
|
||||||
status: idx % 3 === 0 ? "in_progress" : "todo",
|
|
||||||
order: 2,
|
|
||||||
dueAt: dueBase,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dealId: deal.id,
|
|
||||||
title: "Согласовать и отправить договор",
|
|
||||||
description: "Выслать договор и зафиксировать дату подписи.",
|
|
||||||
status: "todo",
|
|
||||||
order: 3,
|
|
||||||
dueAt: atOffset((idx % 5) + 6, 15, 0),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await prisma.dealStep.createMany({ data: steps });
|
|
||||||
const current = await prisma.dealStep.findFirst({
|
|
||||||
where: { dealId: deal.id, status: { not: "done" } },
|
|
||||||
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
await prisma.deal.update({
|
|
||||||
where: { id: deal.id },
|
|
||||||
data: { currentStepId: current?.id ?? null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.contactPin.createMany({
|
|
||||||
data: contacts.map((c, idx) => ({
|
|
||||||
teamId: team.id,
|
|
||||||
contactId: c.id,
|
|
||||||
text:
|
|
||||||
idx % 3 === 0
|
|
||||||
? "Уточнить владельца ERP, владельца данных и целевой квартал запуска."
|
|
||||||
: "Держать коммуникацию вокруг одного KPI и следующего шага.",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const proposalKeys = ["create_followup", "open_comm", "call", "draft_message", "run_summary", "prepare_question"];
|
|
||||||
await prisma.feedCard.createMany({
|
|
||||||
data: contacts
|
|
||||||
.filter((_, idx) => idx % 3 === 0)
|
|
||||||
.slice(0, 80)
|
|
||||||
.map((c, idx) => ({
|
|
||||||
teamId: team.id,
|
|
||||||
contactId: c.id,
|
|
||||||
happenedAt: atOffset(-(idx % 6), 9 + (idx % 8), (idx * 9) % 60),
|
|
||||||
text:
|
|
||||||
`Я проверил активность по аккаунту ${c.company ?? c.name} в рамках сделки Odoo + AI. ` +
|
|
||||||
"Есть достаточный импульс, чтобы перевести сделку на следующий этап при чётком следующем шаге.",
|
|
||||||
proposalJson: {
|
|
||||||
title: idx % 2 === 0 ? "Назначить созвон по рамкам пилота" : "Отправить сообщение для разблокировки у владельца бюджета",
|
|
||||||
details: [
|
|
||||||
`Контакт: ${c.name}`,
|
|
||||||
idx % 2 === 0 ? "Когда: на этой неделе, 45 минут" : "Когда: сегодня в основном канале",
|
|
||||||
"Цель: подтвердить объём, владельца и следующую коммерческую контрольную точку",
|
|
||||||
],
|
|
||||||
key: proposalKeys[idx % proposalKeys.length],
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.workspaceDocument.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Чеклист уточнения для интеграции Odoo",
|
|
||||||
type: "Regulation",
|
|
||||||
owner: "Команда решений",
|
|
||||||
scope: "Предпродажное уточнение",
|
|
||||||
summary: "Обязательные вопросы перед оценкой запуска Odoo + AI.",
|
|
||||||
body: "## Нужно зафиксировать\n- Текущие модули ERP\n- Точки интеграции\n- Владельца данных по каждому домену\n- Ограничения безопасности\n- Базовые KPI пилота",
|
|
||||||
updatedAt: atOffset(-1, 11, 10),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Плейбук AI-копилота для Odoo",
|
|
||||||
type: "Playbook",
|
|
||||||
owner: "Лид AI-практики",
|
|
||||||
scope: "Квалификация сценариев",
|
|
||||||
summary: "Как позиционировать прогнозирование, ассистента и детекцию аномалий.",
|
|
||||||
body: "## Поток\n1. Боль процесса\n2. Качество данных\n3. Целевая модель\n4. KPI успеха\n5. Объём пилота",
|
|
||||||
updatedAt: atOffset(-2, 15, 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Матрица цен для пилота",
|
|
||||||
type: "Policy",
|
|
||||||
owner: "Коммерческие операции",
|
|
||||||
scope: "Контракты уточнения и пилота",
|
|
||||||
summary: "Диапазоны цен для уточнения, пилота и продуктивной фазы.",
|
|
||||||
body: "## Типовые диапазоны\n- Уточнение: 5k-12k\n- Пилот: 15k-45k\n- Тиражирование: 50k+\n\nВсегда привязывай стоимость к объёму и срокам.",
|
|
||||||
updatedAt: atOffset(-3, 9, 30),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Шаблон по безопасности и комплаенсу",
|
|
||||||
type: "Template",
|
|
||||||
owner: "Офис внедрения",
|
|
||||||
scope: "Крупные клиенты",
|
|
||||||
summary: "Шаблон ответов по data residency, RBAC, аудиту и обработке PII.",
|
|
||||||
body: "## Разделы\n- Модель хостинга\n- Контроль доступа\n- Логирование и аудит\n- Срок хранения данных\n- Реакция на инциденты",
|
|
||||||
updatedAt: atOffset(-4, 13, 45),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Референс интеграционной архитектуры",
|
|
||||||
type: "Playbook",
|
|
||||||
owner: "Архитектурная команда",
|
|
||||||
scope: "Технические воркшопы",
|
|
||||||
summary: "Референс-архитектура для коннекторов Odoo, ETL и AI-сервисного слоя.",
|
|
||||||
body: "## Слои\n- Базовые модули Odoo\n- Интеграционная шина\n- Хранилище данных\n- Эндпоинты AI-сервиса\n- Мониторинг",
|
|
||||||
updatedAt: atOffset(-5, 10, 0),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
title: "Чеклист готовности к запуску",
|
|
||||||
type: "Regulation",
|
|
||||||
owner: "PMO",
|
|
||||||
scope: "Переход от пилота к продакшену",
|
|
||||||
summary: "Чеклист перехода от приёмки пилота к запуску в прод.",
|
|
||||||
body: "## Обязательно\n- KPI пилота утверждены\n- Backlog тиражирования приоритизирован\n- Владельцы назначены\n- Модель поддержки определена",
|
|
||||||
updatedAt: atOffset(-6, 16, 15),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Seed completed.");
|
|
||||||
console.log(`Login phone: ${LOGIN_PHONE}`);
|
|
||||||
console.log(`Login password: ${LOGIN_PASSWORD}`);
|
|
||||||
console.log(`Team: ${team.name}`);
|
|
||||||
console.log(`Contacts created: ${contacts.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exitCode = 1;
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
# Clean previous build artifacts before production build.
|
|
||||||
mkdir -p .nuxt .output
|
|
||||||
find .nuxt -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
|
|
||||||
find .output -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
|
|
||||||
rm -rf node_modules/.cache node_modules/.vite
|
|
||||||
|
|
||||||
# Install deps (container starts from a clean image).
|
|
||||||
# This workspace has mixed Apollo/Nuxt peer graphs; keep install deterministic in Docker.
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
|
|
||||||
# sharp is a native module and can break when cached node_modules were installed
|
|
||||||
# for a different CPU variant (for example arm64v8). Force a local rebuild.
|
|
||||||
ARCH="$(uname -m)"
|
|
||||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
|
||||||
npm rebuild sharp --platform=linux --arch=arm64v8 \
|
|
||||||
|| npm rebuild sharp --platform=linux --arch=arm64 \
|
|
||||||
|| npm install sharp --platform=linux --arch=arm64v8 --save-exact=false \
|
|
||||||
|| npm install sharp --platform=linux --arch=arm64 --save-exact=false
|
|
||||||
elif [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then
|
|
||||||
npm rebuild sharp --platform=linux --arch=x64 \
|
|
||||||
|| npm install sharp --platform=linux --arch=x64 --save-exact=false
|
|
||||||
else
|
|
||||||
npm rebuild sharp || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait until PostgreSQL is reachable before applying schema.
|
|
||||||
until node -e "const u=new URL(process.env.DATABASE_URL||''); const net=require('net'); const s=net.createConnection({host:u.hostname,port:Number(u.port||5432)}); s.on('connect',()=>{s.end(); process.exit(0);}); s.on('error',()=>process.exit(1));" ; do
|
|
||||||
echo "Waiting for PostgreSQL..."
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
npx prisma db push
|
|
||||||
|
|
||||||
# Run Nuxt in production mode (Nitro server), no dev/preview runtime.
|
|
||||||
npm run build
|
|
||||||
export NITRO_HOST=0.0.0.0
|
|
||||||
export NITRO_PORT=3000
|
|
||||||
exec node .output/server/index.mjs
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import fsSync from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
function loadEnvFromDotEnv() {
|
|
||||||
const p = path.resolve(process.cwd(), ".env");
|
|
||||||
if (!fsSync.existsSync(p)) return;
|
|
||||||
const raw = fsSync.readFileSync(p, "utf8");
|
|
||||||
for (const line of raw.split("\n")) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
const idx = trimmed.indexOf("=");
|
|
||||||
if (idx === -1) continue;
|
|
||||||
const key = trimmed.slice(0, idx).trim();
|
|
||||||
let val = trimmed.slice(idx + 1).trim();
|
|
||||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
||||||
val = val.slice(1, -1);
|
|
||||||
}
|
|
||||||
if (!key) continue;
|
|
||||||
if (key === "DATABASE_URL") {
|
|
||||||
process.env[key] = val;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!process.env[key]) process.env[key] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvFromDotEnv();
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
function datasetRoot() {
|
|
||||||
const teamId = process.env.TEAM_ID || "demo-team";
|
|
||||||
const userId = process.env.USER_ID || "demo-user";
|
|
||||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", teamId, "users", userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDir(p) {
|
|
||||||
await fs.mkdir(p, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJson(p, value) {
|
|
||||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonlLine(value) {
|
|
||||||
return JSON.stringify(value) + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const root = datasetRoot();
|
|
||||||
const tmp = root + ".tmp";
|
|
||||||
|
|
||||||
await fs.rm(tmp, { recursive: true, force: true });
|
|
||||||
await ensureDir(tmp);
|
|
||||||
|
|
||||||
const contactsDir = path.join(tmp, "contacts");
|
|
||||||
const notesDir = path.join(tmp, "notes");
|
|
||||||
const messagesDir = path.join(tmp, "messages");
|
|
||||||
const eventsDir = path.join(tmp, "events");
|
|
||||||
const indexDir = path.join(tmp, "index");
|
|
||||||
await Promise.all([
|
|
||||||
ensureDir(contactsDir),
|
|
||||||
ensureDir(notesDir),
|
|
||||||
ensureDir(messagesDir),
|
|
||||||
ensureDir(eventsDir),
|
|
||||||
ensureDir(indexDir),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const teamId = process.env.TEAM_ID || "demo-team";
|
|
||||||
const contacts = await prisma.contact.findMany({
|
|
||||||
where: { teamId },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
include: {
|
|
||||||
note: { select: { content: true, updatedAt: true } },
|
|
||||||
messages: {
|
|
||||||
select: {
|
|
||||||
kind: true,
|
|
||||||
direction: true,
|
|
||||||
channel: true,
|
|
||||||
content: true,
|
|
||||||
durationSec: true,
|
|
||||||
transcriptJson: true,
|
|
||||||
occurredAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { occurredAt: "asc" },
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
|
|
||||||
orderBy: { startsAt: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contactIndex = [];
|
|
||||||
for (const c of contacts) {
|
|
||||||
await writeJson(path.join(contactsDir, `${c.id}.json`), {
|
|
||||||
id: c.id,
|
|
||||||
teamId: c.teamId,
|
|
||||||
name: c.name,
|
|
||||||
company: c.company ?? null,
|
|
||||||
country: c.country ?? null,
|
|
||||||
location: c.location ?? null,
|
|
||||||
avatarUrl: c.avatarUrl ?? null,
|
|
||||||
email: c.email ?? null,
|
|
||||||
phone: c.phone ?? null,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(notesDir, `${c.id}.md`),
|
|
||||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(messagesDir, `${c.id}.jsonl`),
|
|
||||||
c.messages
|
|
||||||
.map((m) =>
|
|
||||||
jsonlLine({
|
|
||||||
kind: m.kind,
|
|
||||||
direction: m.direction,
|
|
||||||
channel: m.channel,
|
|
||||||
occurredAt: m.occurredAt,
|
|
||||||
content: m.content,
|
|
||||||
durationSec: m.durationSec ?? null,
|
|
||||||
transcript: m.transcriptJson ?? null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.join(""),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(eventsDir, `${c.id}.jsonl`),
|
|
||||||
c.events
|
|
||||||
.map((e) =>
|
|
||||||
jsonlLine({
|
|
||||||
title: e.title,
|
|
||||||
startsAt: e.startsAt,
|
|
||||||
endsAt: e.endsAt,
|
|
||||||
status: e.status ?? null,
|
|
||||||
note: e.note ?? null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.join(""),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
|
|
||||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
|
||||||
contactIndex.push({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
company: c.company ?? null,
|
|
||||||
lastMessageAt,
|
|
||||||
nextEventAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
|
||||||
await writeJson(path.join(tmp, "meta.json"), { exportedAt: new Date().toISOString(), version: 1 });
|
|
||||||
|
|
||||||
await ensureDir(path.dirname(root));
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
await fs.rename(tmp, root);
|
|
||||||
|
|
||||||
console.log("exported", root);
|
|
||||||
}
|
|
||||||
|
|
||||||
await main()
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exitCode = 1;
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { ChatRole, Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { datasetRoot } from "../dataset/paths";
|
|
||||||
import { ensureDataset } from "../dataset/exporter";
|
|
||||||
import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent";
|
|
||||||
import type { ChangeSet } from "../utils/changeSet";
|
|
||||||
|
|
||||||
type ContactIndexRow = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
company: string | null;
|
|
||||||
lastMessageAt: string | null;
|
|
||||||
nextEventAt: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentReply = {
|
|
||||||
text: string;
|
|
||||||
plan: string[];
|
|
||||||
tools: string[];
|
|
||||||
thinking?: string[];
|
|
||||||
toolRuns?: Array<{
|
|
||||||
name: string;
|
|
||||||
status: "ok" | "error";
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
at: string;
|
|
||||||
}>;
|
|
||||||
dbWrites?: Array<{ kind: string; detail: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentTraceEvent = {
|
|
||||||
text: string;
|
|
||||||
toolRun?: {
|
|
||||||
name: string;
|
|
||||||
status: "ok" | "error";
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
at: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PilotContextPayload = {
|
|
||||||
scopes: Array<"summary" | "deal" | "message" | "calendar">;
|
|
||||||
summary?: {
|
|
||||||
contactId: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
deal?: {
|
|
||||||
dealId: string;
|
|
||||||
title: string;
|
|
||||||
contact: string;
|
|
||||||
};
|
|
||||||
message?: {
|
|
||||||
contactId?: string;
|
|
||||||
contact?: string;
|
|
||||||
intent: "add_message_or_reminder";
|
|
||||||
};
|
|
||||||
calendar?: {
|
|
||||||
view: "day" | "week" | "month" | "year" | "agenda";
|
|
||||||
period: string;
|
|
||||||
selectedDateKey: string;
|
|
||||||
focusedEventId?: string;
|
|
||||||
eventIds: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalize(s: string) {
|
|
||||||
return s.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToday(date: Date) {
|
|
||||||
const now = new Date();
|
|
||||||
return (
|
|
||||||
date.getFullYear() === now.getFullYear() &&
|
|
||||||
date.getMonth() === now.getMonth() &&
|
|
||||||
date.getDate() === now.getDate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readContactIndex(): Promise<ContactIndexRow[]> {
|
|
||||||
throw new Error("readContactIndex now requires dataset root");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readContactIndexFrom(root: string): Promise<ContactIndexRow[]> {
|
|
||||||
const p = path.join(root, "index", "contacts.json");
|
|
||||||
const raw = await fs.readFile(p, "utf8");
|
|
||||||
return JSON.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function countJsonlLines(p: string): Promise<number> {
|
|
||||||
const raw = await fs.readFile(p, "utf8");
|
|
||||||
if (!raw.trim()) return 0;
|
|
||||||
// cheap line count (JSONL is 1 item per line)
|
|
||||||
return raw.trimEnd().split("\n").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonl(p: string): Promise<any[]> {
|
|
||||||
const raw = await fs.readFile(p, "utf8");
|
|
||||||
if (!raw.trim()) return [];
|
|
||||||
return raw
|
|
||||||
.trimEnd()
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => JSON.parse(line));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContactLine(c: ContactIndexRow) {
|
|
||||||
const company = c.company ? ` (${c.company})` : "";
|
|
||||||
const lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет";
|
|
||||||
return `- ${c.name}${company} · последнее: ${lastAt}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCrmAgent(userText: string): Promise<AgentReply> {
|
|
||||||
throw new Error("runCrmAgent now requires auth context");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCrmAgentFor(
|
|
||||||
input: {
|
|
||||||
teamId: string;
|
|
||||||
userId: string;
|
|
||||||
userText: string;
|
|
||||||
contextPayload?: PilotContextPayload | null;
|
|
||||||
requestId?: string;
|
|
||||||
conversationId?: string;
|
|
||||||
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
|
|
||||||
},
|
|
||||||
): Promise<AgentReply> {
|
|
||||||
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
|
|
||||||
const llmApiKey =
|
|
||||||
process.env.OPENROUTER_API_KEY ||
|
|
||||||
process.env.LLM_API_KEY ||
|
|
||||||
process.env.OPENAI_API_KEY ||
|
|
||||||
process.env.DASHSCOPE_API_KEY ||
|
|
||||||
process.env.QWEN_API_KEY;
|
|
||||||
const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim());
|
|
||||||
|
|
||||||
if (mode !== "rule") {
|
|
||||||
return runLangGraphCrmAgentFor(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!llmApiKey && !hasGigachat) {
|
|
||||||
throw new Error("LLM API key is not configured. Set OPENROUTER_API_KEY or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureDataset({ teamId: input.teamId, userId: input.userId });
|
|
||||||
const q = normalize(input.userText);
|
|
||||||
const root = datasetRoot({ teamId: input.teamId, userId: input.userId });
|
|
||||||
const contacts = await readContactIndexFrom(root);
|
|
||||||
|
|
||||||
// "10 лучших клиентов"
|
|
||||||
if (q.includes("10 лучших") || (q.includes("топ") && q.includes("клиент"))) {
|
|
||||||
const ranked = await Promise.all(
|
|
||||||
contacts.map(async (c) => {
|
|
||||||
const msgPath = path.join(root, "messages", `${c.id}.jsonl`);
|
|
||||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
|
||||||
const msgCount = await countJsonlLines(msgPath).catch(() => 0);
|
|
||||||
const ev = await readJsonl(evPath).catch(() => []);
|
|
||||||
const todayEvCount = ev.filter((e) => (e?.startsAt ? isToday(new Date(e.startsAt)) : false)).length;
|
|
||||||
const score = msgCount * 2 + todayEvCount * 3;
|
|
||||||
return { c, score };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
ranked.sort((a, b) => b.score - a.score);
|
|
||||||
const top = ranked.slice(0, 10).map((x) => x.c);
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: [
|
|
||||||
"Загрузить индекс контактов из файлового датасета",
|
|
||||||
"Посчитать активность по JSONL (сообщения/события сегодня)",
|
|
||||||
"Отсортировать и показать топ",
|
|
||||||
],
|
|
||||||
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
|
|
||||||
toolRuns: [
|
|
||||||
{
|
|
||||||
name: "dataset:index_contacts",
|
|
||||||
status: "ok",
|
|
||||||
input: "index/contacts.json",
|
|
||||||
output: "Loaded contacts index",
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text:
|
|
||||||
`Топ-10 по активности (сообщения + события):\n` +
|
|
||||||
top.map(formatContactLine).join("\n") +
|
|
||||||
`\n\nЕсли хочешь, скажи критерий "лучший" (выручка/стадия/вероятность/давность) и я пересчитаю.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// "чем заняться сегодня"
|
|
||||||
if (q.includes("чем") && (q.includes("сегодня") || q.includes("заняться"))) {
|
|
||||||
const todayEvents: Array<{ who: string; title: string; at: Date; note?: string | null }> = [];
|
|
||||||
|
|
||||||
for (const c of contacts) {
|
|
||||||
const evPath = path.join(root, "events", `${c.id}.jsonl`);
|
|
||||||
const ev = await readJsonl(evPath).catch(() => []);
|
|
||||||
for (const e of ev) {
|
|
||||||
if (!e?.startsAt) continue;
|
|
||||||
const at = new Date(e.startsAt);
|
|
||||||
if (!isToday(at)) continue;
|
|
||||||
todayEvents.push({ who: c.name, title: e.title ?? "Event", at, note: e.note ?? null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
todayEvents.sort((a, b) => a.at.getTime() - b.at.getTime());
|
|
||||||
|
|
||||||
const followups = [...contacts]
|
|
||||||
.map((c) => ({ c, last: c.lastMessageAt ? new Date(c.lastMessageAt).getTime() : 0 }))
|
|
||||||
.sort((a, b) => a.last - b.last)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((x) => x.c);
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (todayEvents.length > 0) {
|
|
||||||
lines.push("Сегодня по календарю:");
|
|
||||||
for (const e of todayEvents) {
|
|
||||||
const hhmm = e.at.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
|
||||||
lines.push(`- ${hhmm} · ${e.title} · ${e.who}${e.note ? ` · ${e.note}` : ""}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lines.push("Сегодня нет запланированных событий в календаре.");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
lines.push("Фокус дня (если нужно добить прогресс):");
|
|
||||||
for (const c of followups) {
|
|
||||||
lines.push(`- Написать follow-up: ${c.name}${c.company ? ` (${c.company})` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: [
|
|
||||||
"Прочитать события на сегодня из файлового датасета",
|
|
||||||
"Найти контакты без свежего касания (по lastMessageAt)",
|
|
||||||
"Сформировать короткий список действий",
|
|
||||||
],
|
|
||||||
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
|
|
||||||
toolRuns: [
|
|
||||||
{
|
|
||||||
name: "dataset:query_events",
|
|
||||||
status: "ok",
|
|
||||||
input: "events/*.jsonl (today)",
|
|
||||||
output: `Found ${todayEvents.length} events`,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: lines.join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
"Rule mode supports only structured built-in queries. Use a supported query or switch to langgraph mode with a configured LLM API key.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function persistChatMessage(input: {
|
|
||||||
role: ChatRole;
|
|
||||||
text: string;
|
|
||||||
plan?: string[];
|
|
||||||
tools?: string[];
|
|
||||||
thinking?: string[];
|
|
||||||
toolRuns?: Array<{
|
|
||||||
name: string;
|
|
||||||
status: "ok" | "error";
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
at: string;
|
|
||||||
}>;
|
|
||||||
changeSet?: ChangeSet | null;
|
|
||||||
requestId?: string;
|
|
||||||
eventType?: "user" | "trace" | "assistant" | "note";
|
|
||||||
phase?: "pending" | "running" | "final" | "error";
|
|
||||||
transient?: boolean;
|
|
||||||
messageKind?: "change_set_summary";
|
|
||||||
teamId: string;
|
|
||||||
conversationId: string;
|
|
||||||
authorUserId?: string | null;
|
|
||||||
}) {
|
|
||||||
const hasStoredPayload = Boolean(input.changeSet || input.messageKind);
|
|
||||||
const data: Prisma.ChatMessageCreateInput = {
|
|
||||||
team: { connect: { id: input.teamId } },
|
|
||||||
conversation: { connect: { id: input.conversationId } },
|
|
||||||
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
|
|
||||||
role: input.role,
|
|
||||||
text: input.text,
|
|
||||||
planJson: hasStoredPayload
|
|
||||||
? ({
|
|
||||||
messageKind: input.messageKind ?? null,
|
|
||||||
changeSet: input.changeSet ?? null,
|
|
||||||
} as any)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
return prisma.chatMessage.create({ data });
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { graphql } from "graphql";
|
|
||||||
import { getAuthContext } from "../utils/auth";
|
|
||||||
import { crmGraphqlRoot, crmGraphqlSchema } from "../graphql/schema";
|
|
||||||
|
|
||||||
type GraphqlBody = {
|
|
||||||
query?: string;
|
|
||||||
operationName?: string;
|
|
||||||
variables?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const body = await readBody<GraphqlBody>(event);
|
|
||||||
|
|
||||||
if (!body?.query || !body.query.trim()) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "GraphQL query is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth = null;
|
|
||||||
try {
|
|
||||||
auth = await getAuthContext(event);
|
|
||||||
} catch {
|
|
||||||
auth = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await graphql({
|
|
||||||
schema: crmGraphqlSchema,
|
|
||||||
source: body.query,
|
|
||||||
rootValue: crmGraphqlRoot,
|
|
||||||
contextValue: { auth, event },
|
|
||||||
variableValues: body.variables,
|
|
||||||
operationName: body.operationName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: result.data ?? null,
|
|
||||||
errors: result.errors?.map((error) => ({ message: error.message })) ?? undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { getAuthContext } from "../../../utils/auth";
|
|
||||||
import { prisma } from "../../../utils/prisma";
|
|
||||||
import { enqueueOutboundDelivery } from "../../../queues/outboundDelivery";
|
|
||||||
|
|
||||||
type EnqueueBody = {
|
|
||||||
omniMessageId?: string;
|
|
||||||
endpoint?: string;
|
|
||||||
method?: "POST" | "PUT" | "PATCH";
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
payload?: unknown;
|
|
||||||
provider?: string;
|
|
||||||
channel?: string;
|
|
||||||
attempts?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const body = await readBody<EnqueueBody>(event);
|
|
||||||
|
|
||||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
|
||||||
const endpoint = String(body?.endpoint ?? "").trim();
|
|
||||||
if (!omniMessageId) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
|
||||||
}
|
|
||||||
if (!endpoint) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "endpoint is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = await prisma.omniMessage.findFirst({
|
|
||||||
where: { id: omniMessageId, teamId: auth.teamId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!msg) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: "omni message not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
|
||||||
const job = await enqueueOutboundDelivery(
|
|
||||||
{
|
|
||||||
omniMessageId,
|
|
||||||
endpoint,
|
|
||||||
method: body?.method ?? "POST",
|
|
||||||
headers: body?.headers ?? {},
|
|
||||||
payload: body?.payload ?? {},
|
|
||||||
provider: body?.provider ?? undefined,
|
|
||||||
channel: body?.channel ?? undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attempts,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow",
|
|
||||||
jobId: job.id,
|
|
||||||
omniMessageId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { prisma } from "../../../../../utils/prisma";
|
|
||||||
|
|
||||||
type CompleteBody = {
|
|
||||||
token?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const body = await readBody<CompleteBody>(event);
|
|
||||||
const token = String(body?.token ?? "").trim();
|
|
||||||
if (!token) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "token is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingId = `pending:${token}`;
|
|
||||||
const pending = await prisma.telegramBusinessConnection.findFirst({
|
|
||||||
where: {
|
|
||||||
businessConnectionId: pendingId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pending) {
|
|
||||||
return { ok: false, status: "session_not_found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = (pending.rawJson ?? {}) as any;
|
|
||||||
const exp = Number(raw?.link?.exp ?? 0);
|
|
||||||
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
|
|
||||||
return { ok: false, status: "invalid_or_expired_token" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId).trim() : "";
|
|
||||||
if (!telegramUserId) {
|
|
||||||
return { ok: false, status: "awaiting_telegram_start" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkedConnectionId = `link:${telegramUserId}`;
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.telegramBusinessConnection.upsert({
|
|
||||||
where: {
|
|
||||||
teamId_businessConnectionId: {
|
|
||||||
teamId: pending.teamId,
|
|
||||||
businessConnectionId: linkedConnectionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
teamId: pending.teamId,
|
|
||||||
businessConnectionId: linkedConnectionId,
|
|
||||||
isEnabled: true,
|
|
||||||
canReply: true,
|
|
||||||
rawJson: {
|
|
||||||
state: "connected",
|
|
||||||
mode: "token_link",
|
|
||||||
linkedAt: new Date().toISOString(),
|
|
||||||
telegramUserId,
|
|
||||||
tokenNonce: token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
isEnabled: true,
|
|
||||||
canReply: true,
|
|
||||||
rawJson: {
|
|
||||||
state: "connected",
|
|
||||||
mode: "token_link",
|
|
||||||
linkedAt: new Date().toISOString(),
|
|
||||||
telegramUserId,
|
|
||||||
tokenNonce: token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.telegramBusinessConnection.delete({ where: { id: pending.id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: "connected",
|
|
||||||
businessConnectionId: linkedConnectionId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { getAuthContext } from "../../../../../utils/auth";
|
|
||||||
import { prisma } from "../../../../../utils/prisma";
|
|
||||||
import { telegramBotApi } from "../../../../../utils/telegram";
|
|
||||||
|
|
||||||
type RefreshBody = {
|
|
||||||
businessConnectionId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function mapFlags(raw: any) {
|
|
||||||
const isEnabled = typeof raw?.is_enabled === "boolean" ? raw.is_enabled : null;
|
|
||||||
const canReply = typeof raw?.can_reply === "boolean"
|
|
||||||
? raw.can_reply
|
|
||||||
: typeof raw?.rights?.can_reply === "boolean"
|
|
||||||
? raw.rights.can_reply
|
|
||||||
: null;
|
|
||||||
return { isEnabled, canReply };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const body = await readBody<RefreshBody>(event);
|
|
||||||
|
|
||||||
const businessConnectionId = String(body?.businessConnectionId ?? "").trim();
|
|
||||||
if (!businessConnectionId) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "businessConnectionId is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.telegramBusinessConnection.findFirst({
|
|
||||||
where: {
|
|
||||||
teamId: auth.teamId,
|
|
||||||
businessConnectionId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: "business connection not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await telegramBotApi<any>("getBusinessConnection", { business_connection_id: businessConnectionId });
|
|
||||||
const { isEnabled, canReply } = mapFlags(response);
|
|
||||||
|
|
||||||
const updated = await prisma.telegramBusinessConnection.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
isEnabled,
|
|
||||||
canReply,
|
|
||||||
rawJson: {
|
|
||||||
state: "connected",
|
|
||||||
refreshedAt: new Date().toISOString(),
|
|
||||||
businessConnection: response,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
businessConnectionId: true,
|
|
||||||
isEnabled: true,
|
|
||||||
canReply: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
connection: {
|
|
||||||
businessConnectionId: updated.businessConnectionId,
|
|
||||||
isEnabled: updated.isEnabled,
|
|
||||||
canReply: updated.canReply,
|
|
||||||
updatedAt: updated.updatedAt.toISOString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { getAuthContext } from "../../../../../utils/auth";
|
|
||||||
import { prisma } from "../../../../../utils/prisma";
|
|
||||||
import { buildTelegramStartUrl, issueLinkToken } from "../../../../../utils/telegramBusinessConnect";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const { token, payload } = issueLinkToken({ teamId: auth.teamId, userId: auth.userId });
|
|
||||||
|
|
||||||
const pendingId = `pending:${payload.nonce}`;
|
|
||||||
await prisma.telegramBusinessConnection.upsert({
|
|
||||||
where: {
|
|
||||||
teamId_businessConnectionId: {
|
|
||||||
teamId: auth.teamId,
|
|
||||||
businessConnectionId: pendingId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
teamId: auth.teamId,
|
|
||||||
businessConnectionId: pendingId,
|
|
||||||
rawJson: {
|
|
||||||
state: "pending_link",
|
|
||||||
link: {
|
|
||||||
nonce: payload.nonce,
|
|
||||||
exp: payload.exp,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
createdByUserId: auth.userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
isEnabled: null,
|
|
||||||
canReply: null,
|
|
||||||
rawJson: {
|
|
||||||
state: "pending_link",
|
|
||||||
link: {
|
|
||||||
nonce: payload.nonce,
|
|
||||||
exp: payload.exp,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
createdByUserId: auth.userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status: "pending_link",
|
|
||||||
connectUrl: buildTelegramStartUrl(token),
|
|
||||||
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { getAuthContext } from "../../../../../utils/auth";
|
|
||||||
import { prisma } from "../../../../../utils/prisma";
|
|
||||||
|
|
||||||
function normalizeStatus(input: {
|
|
||||||
pendingCount: number;
|
|
||||||
linkedPendingCount: number;
|
|
||||||
connectedCount: number;
|
|
||||||
enabledCount: number;
|
|
||||||
replyEnabledCount: number;
|
|
||||||
}) {
|
|
||||||
if (input.connectedCount > 0) {
|
|
||||||
if (input.replyEnabledCount > 0 && input.enabledCount > 0) return "connected";
|
|
||||||
if (input.enabledCount === 0) return "disabled";
|
|
||||||
return "no_reply_rights";
|
|
||||||
}
|
|
||||||
if (input.linkedPendingCount > 0) return "pending_business_connection";
|
|
||||||
if (input.pendingCount > 0) return "pending_link";
|
|
||||||
return "not_connected";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const rows = await prisma.telegramBusinessConnection.findMany({
|
|
||||||
where: { teamId: auth.teamId },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pending = rows.filter((r) => r.businessConnectionId.startsWith("pending:"));
|
|
||||||
const active = rows.filter((r) => !r.businessConnectionId.startsWith("pending:"));
|
|
||||||
|
|
||||||
const linkedPendingCount = pending.filter((r) => {
|
|
||||||
const raw = (r.rawJson ?? {}) as any;
|
|
||||||
return Boolean(raw?.link?.telegramUserId || raw?.link?.chatId);
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
const enabledCount = active.filter((r) => r.isEnabled !== false).length;
|
|
||||||
const replyEnabledCount = active.filter((r) => r.canReply === true).length;
|
|
||||||
|
|
||||||
const status = normalizeStatus({
|
|
||||||
pendingCount: pending.length,
|
|
||||||
linkedPendingCount,
|
|
||||||
connectedCount: active.length,
|
|
||||||
enabledCount,
|
|
||||||
replyEnabledCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
status,
|
|
||||||
pendingCount: pending.length,
|
|
||||||
connectedCount: active.length,
|
|
||||||
connections: active.map((r) => ({
|
|
||||||
businessConnectionId: r.businessConnectionId,
|
|
||||||
isEnabled: r.isEnabled,
|
|
||||||
canReply: r.canReply,
|
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
import { getHeader, readBody } from "h3";
|
|
||||||
import { prisma } from "../../../../utils/prisma";
|
|
||||||
import { telegramBotApi } from "../../../../utils/telegram";
|
|
||||||
import {
|
|
||||||
extractLinkTokenFromStartText,
|
|
||||||
getBusinessConnectionFromUpdate,
|
|
||||||
getTelegramChatIdFromUpdate,
|
|
||||||
} from "../../../../utils/telegramBusinessConnect";
|
|
||||||
|
|
||||||
function hasValidSecret(event: any) {
|
|
||||||
const expected = String(process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
|
||||||
if (!expected) return true;
|
|
||||||
const incoming = String(getHeader(event, "x-telegram-bot-api-secret-token") || "").trim();
|
|
||||||
return incoming !== "" && incoming === expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickStartText(update: any): string | null {
|
|
||||||
const text =
|
|
||||||
update?.message?.text ??
|
|
||||||
update?.business_message?.text ??
|
|
||||||
update?.edited_business_message?.text ??
|
|
||||||
null;
|
|
||||||
if (typeof text !== "string") return null;
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function crmConnectUrl() {
|
|
||||||
return String(process.env.CRM_APP_URL || "https://clientsflow.dsrptlab.com").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function crmConnectButton(linkToken?: string) {
|
|
||||||
const base = crmConnectUrl();
|
|
||||||
let target = base;
|
|
||||||
if (linkToken) {
|
|
||||||
try {
|
|
||||||
const u = new URL(base);
|
|
||||||
u.searchParams.set("tg_link_token", linkToken);
|
|
||||||
target = u.toString();
|
|
||||||
} catch {
|
|
||||||
target = `${base}${base.includes("?") ? "&" : "?"}tg_link_token=${encodeURIComponent(linkToken)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inline_keyboard: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Открыть CRM и подтвердить",
|
|
||||||
url: target,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
if (!hasValidSecret(event)) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: "invalid webhook secret" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = await readBody<any>(event);
|
|
||||||
const nowIso = new Date().toISOString();
|
|
||||||
|
|
||||||
const startText = pickStartText(update);
|
|
||||||
const linkToken = startText ? extractLinkTokenFromStartText(startText) : null;
|
|
||||||
const startChatId = getTelegramChatIdFromUpdate(update);
|
|
||||||
|
|
||||||
if (startText && !linkToken) {
|
|
||||||
if (startChatId) {
|
|
||||||
void telegramBotApi("sendMessage", {
|
|
||||||
chat_id: startChatId,
|
|
||||||
text: "Чтобы привязать Telegram Business к CRM, открой CRM → Settings → Telegram Business → Connect. Кнопка сгенерирует персональную ссылку привязки.",
|
|
||||||
reply_markup: crmConnectButton(),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
return { ok: true, accepted: true, type: "start_without_link_token" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linkToken) {
|
|
||||||
const pendingId = `pending:${linkToken}`;
|
|
||||||
const pending = await prisma.telegramBusinessConnection.findFirst({
|
|
||||||
where: {
|
|
||||||
businessConnectionId: pendingId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!pending) {
|
|
||||||
if (startChatId) {
|
|
||||||
void telegramBotApi("sendMessage", {
|
|
||||||
chat_id: startChatId,
|
|
||||||
text: "Ссылка привязки недействительна или истекла. Вернись в CRM и нажми Connect заново.",
|
|
||||||
reply_markup: crmConnectButton(),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawPending = (pending.rawJson ?? {}) as any;
|
|
||||||
const exp = Number(rawPending?.link?.exp ?? 0);
|
|
||||||
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
|
|
||||||
if (startChatId) {
|
|
||||||
void telegramBotApi("sendMessage", {
|
|
||||||
chat_id: startChatId,
|
|
||||||
text: "Ссылка привязки истекла. Вернись в CRM и нажми Connect заново.",
|
|
||||||
reply_markup: crmConnectButton(),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatId = startChatId;
|
|
||||||
|
|
||||||
await prisma.telegramBusinessConnection.updateMany({
|
|
||||||
where: {
|
|
||||||
teamId: pending.teamId,
|
|
||||||
businessConnectionId: pendingId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
rawJson: {
|
|
||||||
state: "pending_business_connection",
|
|
||||||
link: {
|
|
||||||
...(rawPending?.link ?? {}),
|
|
||||||
linkedAt: nowIso,
|
|
||||||
telegramUserId: chatId,
|
|
||||||
chatId,
|
|
||||||
},
|
|
||||||
lastStartUpdate: update,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (chatId) {
|
|
||||||
void telegramBotApi("sendMessage", {
|
|
||||||
chat_id: chatId,
|
|
||||||
text: "CRM: связка аккаунта получена. Нажми кнопку ниже и вернись в CRM для подтверждения.",
|
|
||||||
reply_markup: crmConnectButton(linkToken),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, accepted: true, type: "start_link" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const businessConnection = getBusinessConnectionFromUpdate(update);
|
|
||||||
if (businessConnection) {
|
|
||||||
const pendingRows = await prisma.telegramBusinessConnection.findMany({
|
|
||||||
where: {
|
|
||||||
businessConnectionId: {
|
|
||||||
startsWith: "pending:",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
const matchedPending = pendingRows.find((row) => {
|
|
||||||
const raw = (row.rawJson ?? {}) as any;
|
|
||||||
const linkedTelegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId) : null;
|
|
||||||
if (!businessConnection.userChatId) return false;
|
|
||||||
return linkedTelegramUserId === businessConnection.userChatId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!matchedPending) {
|
|
||||||
return { ok: true, accepted: false, reason: "team_not_linked_for_business_connection" };
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.telegramBusinessConnection.upsert({
|
|
||||||
where: {
|
|
||||||
teamId_businessConnectionId: {
|
|
||||||
teamId: matchedPending.teamId,
|
|
||||||
businessConnectionId: businessConnection.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
teamId: matchedPending.teamId,
|
|
||||||
businessConnectionId: businessConnection.id,
|
|
||||||
isEnabled: businessConnection.isEnabled,
|
|
||||||
canReply: businessConnection.canReply,
|
|
||||||
rawJson: {
|
|
||||||
state: "connected",
|
|
||||||
connectedAt: nowIso,
|
|
||||||
userChatId: businessConnection.userChatId,
|
|
||||||
businessConnection: businessConnection.raw,
|
|
||||||
update,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
isEnabled: businessConnection.isEnabled,
|
|
||||||
canReply: businessConnection.canReply,
|
|
||||||
rawJson: {
|
|
||||||
state: "connected",
|
|
||||||
connectedAt: nowIso,
|
|
||||||
userChatId: businessConnection.userChatId,
|
|
||||||
businessConnection: businessConnection.raw,
|
|
||||||
update,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.telegramBusinessConnection.delete({ where: { id: matchedPending.id } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (businessConnection.userChatId) {
|
|
||||||
void telegramBotApi("sendMessage", {
|
|
||||||
chat_id: businessConnection.userChatId,
|
|
||||||
text: "CRM: Telegram Business подключен. Теперь входящие сообщения будут появляться в CRM.",
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, accepted: true, type: "business_connection" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, accepted: true, type: "ignored" };
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { getAuthContext } from "../../../utils/auth";
|
|
||||||
import { prisma } from "../../../utils/prisma";
|
|
||||||
import { enqueueTelegramSend } from "../../../queues/telegramSend";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const body = await readBody<{ omniMessageId?: string; attempts?: number }>(event);
|
|
||||||
|
|
||||||
const omniMessageId = String(body?.omniMessageId ?? "").trim();
|
|
||||||
if (!omniMessageId) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = await prisma.omniMessage.findFirst({
|
|
||||||
where: { id: omniMessageId, teamId: auth.teamId, channel: "TELEGRAM", direction: "OUT" },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!msg) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: "telegram outbound message not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
|
|
||||||
const job = await enqueueTelegramSend({ omniMessageId }, { attempts });
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow",
|
|
||||||
jobId: job.id,
|
|
||||||
omniMessageId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
|
||||||
import { getAuthContext } from "../utils/auth";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
|
|
||||||
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
|
|
||||||
import type { PilotContextPayload } from "../agent/crmAgent";
|
|
||||||
import type { ChangeSet } from "../utils/changeSet";
|
|
||||||
|
|
||||||
function extractMessageText(message: any): string {
|
|
||||||
if (!message || !Array.isArray(message.parts)) return "";
|
|
||||||
return message.parts
|
|
||||||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
||||||
.map((part: any) => part.text)
|
|
||||||
.join("")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLastUserText(messages: any[]): string {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const message = messages[i];
|
|
||||||
if (message?.role !== "user") continue;
|
|
||||||
const text = extractMessageText(message);
|
|
||||||
if (text) return text;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeContextPayload(raw: unknown): PilotContextPayload | null {
|
|
||||||
if (!raw || typeof raw !== "object") return null;
|
|
||||||
const item = raw as Record<string, any>;
|
|
||||||
const scopesRaw = Array.isArray(item.scopes) ? item.scopes : [];
|
|
||||||
const scopes = scopesRaw
|
|
||||||
.map((scope) => String(scope))
|
|
||||||
.filter((scope) => scope === "summary" || scope === "deal" || scope === "message" || scope === "calendar") as PilotContextPayload["scopes"];
|
|
||||||
if (!scopes.length) return null;
|
|
||||||
|
|
||||||
const payload: PilotContextPayload = { scopes };
|
|
||||||
|
|
||||||
if (item.summary && typeof item.summary === "object") {
|
|
||||||
const contactId = String(item.summary.contactId ?? "").trim();
|
|
||||||
const name = String(item.summary.name ?? "").trim();
|
|
||||||
if (contactId && name) payload.summary = { contactId, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.deal && typeof item.deal === "object") {
|
|
||||||
const dealId = String(item.deal.dealId ?? "").trim();
|
|
||||||
const title = String(item.deal.title ?? "").trim();
|
|
||||||
const contact = String(item.deal.contact ?? "").trim();
|
|
||||||
if (dealId && title && contact) payload.deal = { dealId, title, contact };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.message && typeof item.message === "object") {
|
|
||||||
const contactId = String(item.message.contactId ?? "").trim();
|
|
||||||
const contact = String(item.message.contact ?? "").trim();
|
|
||||||
const intent = String(item.message.intent ?? "").trim();
|
|
||||||
if (intent === "add_message_or_reminder") {
|
|
||||||
payload.message = {
|
|
||||||
...(contactId ? { contactId } : {}),
|
|
||||||
...(contact ? { contact } : {}),
|
|
||||||
intent: "add_message_or_reminder",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.calendar && typeof item.calendar === "object") {
|
|
||||||
const view = String(item.calendar.view ?? "").trim();
|
|
||||||
const period = String(item.calendar.period ?? "").trim();
|
|
||||||
const selectedDateKey = String(item.calendar.selectedDateKey ?? "").trim();
|
|
||||||
const focusedEventId = String(item.calendar.focusedEventId ?? "").trim();
|
|
||||||
const eventIds = Array.isArray(item.calendar.eventIds)
|
|
||||||
? item.calendar.eventIds.map((id: any) => String(id ?? "").trim()).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
if (
|
|
||||||
(view === "day" || view === "week" || view === "month" || view === "year" || view === "agenda") &&
|
|
||||||
period &&
|
|
||||||
selectedDateKey
|
|
||||||
) {
|
|
||||||
payload.calendar = {
|
|
||||||
view,
|
|
||||||
period,
|
|
||||||
selectedDateKey,
|
|
||||||
...(focusedEventId ? { focusedEventId } : {}),
|
|
||||||
eventIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanizeTraceText(trace: AgentTraceEvent): string {
|
|
||||||
if (trace.toolRun?.name) {
|
|
||||||
return `Использую инструмент: ${trace.toolRun.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = (trace.text ?? "").trim();
|
|
||||||
if (!text) return "Агент работает с данными CRM.";
|
|
||||||
|
|
||||||
if (text.toLowerCase().includes("ошиб")) return "Возникла ошибка шага, пробую другой путь.";
|
|
||||||
if (text.toLowerCase().includes("итог")) return "Готовлю финальный ответ.";
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChangeSetSummary(changeSet: ChangeSet): string {
|
|
||||||
const totals = { created: 0, updated: 0, deleted: 0 };
|
|
||||||
for (const item of changeSet.items) {
|
|
||||||
if (item.action === "created") totals.created += 1;
|
|
||||||
else if (item.action === "updated") totals.updated += 1;
|
|
||||||
else if (item.action === "deleted") totals.deleted += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const byEntity = new Map<string, number>();
|
|
||||||
for (const item of changeSet.items) {
|
|
||||||
byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
"Technical change summary",
|
|
||||||
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
|
|
||||||
...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const auth = await getAuthContext(event);
|
|
||||||
const body = await readBody<{ messages?: any[]; contextPayload?: unknown }>(event);
|
|
||||||
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
||||||
const userText = getLastUserText(messages);
|
|
||||||
const contextPayload = sanitizeContextPayload(body?.contextPayload);
|
|
||||||
|
|
||||||
if (!userText) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
|
|
||||||
}
|
|
||||||
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
|
|
||||||
|
|
||||||
const stream = createUIMessageStream({
|
|
||||||
execute: async ({ writer }) => {
|
|
||||||
const textId = `text-${Date.now()}`;
|
|
||||||
writer.write({ type: "start" });
|
|
||||||
try {
|
|
||||||
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
|
|
||||||
|
|
||||||
await persistChatMessage({
|
|
||||||
teamId: auth.teamId,
|
|
||||||
conversationId: auth.conversationId,
|
|
||||||
authorUserId: auth.userId,
|
|
||||||
role: "USER",
|
|
||||||
text: userText,
|
|
||||||
requestId,
|
|
||||||
eventType: "user",
|
|
||||||
phase: "final",
|
|
||||||
transient: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const reply = await runCrmAgentFor({
|
|
||||||
teamId: auth.teamId,
|
|
||||||
userId: auth.userId,
|
|
||||||
userText,
|
|
||||||
contextPayload,
|
|
||||||
requestId,
|
|
||||||
conversationId: auth.conversationId,
|
|
||||||
onTrace: async (trace: AgentTraceEvent) => {
|
|
||||||
writer.write({
|
|
||||||
type: "data-agent-log",
|
|
||||||
data: {
|
|
||||||
requestId,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
text: humanizeTraceText(trace),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshotAfter = await captureSnapshot(prisma, auth.teamId);
|
|
||||||
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
|
|
||||||
|
|
||||||
await persistChatMessage({
|
|
||||||
teamId: auth.teamId,
|
|
||||||
conversationId: auth.conversationId,
|
|
||||||
authorUserId: null,
|
|
||||||
role: "ASSISTANT",
|
|
||||||
text: reply.text,
|
|
||||||
requestId,
|
|
||||||
eventType: "assistant",
|
|
||||||
phase: "final",
|
|
||||||
transient: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (changeSet) {
|
|
||||||
await persistChatMessage({
|
|
||||||
teamId: auth.teamId,
|
|
||||||
conversationId: auth.conversationId,
|
|
||||||
authorUserId: null,
|
|
||||||
role: "ASSISTANT",
|
|
||||||
text: renderChangeSetSummary(changeSet),
|
|
||||||
requestId,
|
|
||||||
eventType: "note",
|
|
||||||
phase: "final",
|
|
||||||
transient: false,
|
|
||||||
messageKind: "change_set_summary",
|
|
||||||
changeSet,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.write({ type: "text-start", id: textId });
|
|
||||||
writer.write({ type: "text-delta", id: textId, delta: reply.text });
|
|
||||||
writer.write({ type: "text-end", id: textId });
|
|
||||||
writer.write({ type: "finish", finishReason: "stop" });
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorText = String(error?.message ?? error);
|
|
||||||
|
|
||||||
await persistChatMessage({
|
|
||||||
teamId: auth.teamId,
|
|
||||||
conversationId: auth.conversationId,
|
|
||||||
authorUserId: null,
|
|
||||||
role: "ASSISTANT",
|
|
||||||
text: errorText,
|
|
||||||
requestId,
|
|
||||||
eventType: "assistant",
|
|
||||||
phase: "error",
|
|
||||||
transient: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
writer.write({
|
|
||||||
type: "data-agent-log",
|
|
||||||
data: {
|
|
||||||
requestId,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
text: "Ошибка выполнения агентского цикла.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
writer.write({ type: "text-start", id: textId });
|
|
||||||
writer.write({
|
|
||||||
type: "text-delta",
|
|
||||||
id: textId,
|
|
||||||
delta: errorText,
|
|
||||||
});
|
|
||||||
writer.write({ type: "text-end", id: textId });
|
|
||||||
writer.write({ type: "finish", finishReason: "stop" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return createUIMessageStreamResponse({ stream });
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { readBody } from "h3";
|
|
||||||
import { getAuthContext } from "../utils/auth";
|
|
||||||
import { transcribeWithWhisper } from "../utils/whisper";
|
|
||||||
|
|
||||||
type TranscribeBody = {
|
|
||||||
audioBase64?: string;
|
|
||||||
sampleRate?: number;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function decodeBase64Pcm16(audioBase64: string) {
|
|
||||||
const pcmBuffer = Buffer.from(audioBase64, "base64");
|
|
||||||
if (pcmBuffer.length < 2) return new Float32Array();
|
|
||||||
|
|
||||||
const sampleCount = Math.floor(pcmBuffer.length / 2);
|
|
||||||
const out = new Float32Array(sampleCount);
|
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i += 1) {
|
|
||||||
const lo = pcmBuffer[i * 2]!;
|
|
||||||
const hi = pcmBuffer[i * 2 + 1]!;
|
|
||||||
const int16 = (hi << 8) | lo;
|
|
||||||
const signed = int16 >= 0x8000 ? int16 - 0x10000 : int16;
|
|
||||||
out[i] = signed / 32768;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
await getAuthContext(event);
|
|
||||||
|
|
||||||
const body = await readBody<TranscribeBody>(event);
|
|
||||||
const audioBase64 = String(body?.audioBase64 ?? "").trim();
|
|
||||||
const sampleRateRaw = Number(body?.sampleRate ?? 0);
|
|
||||||
const language = String(body?.language ?? "").trim() || undefined;
|
|
||||||
|
|
||||||
if (!audioBase64) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "audioBase64 is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isFinite(sampleRateRaw) || sampleRateRaw < 8000 || sampleRateRaw > 48000) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "sampleRate must be between 8000 and 48000" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const samples = decodeBase64Pcm16(audioBase64);
|
|
||||||
if (!samples.length) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "Audio is empty" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSamples = Math.floor(sampleRateRaw * 120);
|
|
||||||
if (samples.length > maxSamples) {
|
|
||||||
throw createError({ statusCode: 413, statusMessage: "Audio is too long (max 120s)" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await transcribeWithWhisper({
|
|
||||||
samples,
|
|
||||||
sampleRate: sampleRateRaw,
|
|
||||||
language,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { text };
|
|
||||||
});
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { datasetRoot } from "./paths";
|
|
||||||
|
|
||||||
type ExportMeta = {
|
|
||||||
exportedAt: string;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function ensureDir(p: string) {
|
|
||||||
await fs.mkdir(p, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJson(p: string, value: unknown) {
|
|
||||||
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonlLine(value: unknown) {
|
|
||||||
return JSON.stringify(value) + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportDatasetFromPrisma() {
|
|
||||||
throw new Error("exportDatasetFromPrisma now requires { teamId, userId }");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
|
|
||||||
const root = datasetRoot(input);
|
|
||||||
const tmp = root + ".tmp";
|
|
||||||
|
|
||||||
await fs.rm(tmp, { recursive: true, force: true });
|
|
||||||
await ensureDir(tmp);
|
|
||||||
|
|
||||||
const contactsDir = path.join(tmp, "contacts");
|
|
||||||
const notesDir = path.join(tmp, "notes");
|
|
||||||
const messagesDir = path.join(tmp, "messages");
|
|
||||||
const eventsDir = path.join(tmp, "events");
|
|
||||||
const indexDir = path.join(tmp, "index");
|
|
||||||
await Promise.all([
|
|
||||||
ensureDir(contactsDir),
|
|
||||||
ensureDir(notesDir),
|
|
||||||
ensureDir(messagesDir),
|
|
||||||
ensureDir(eventsDir),
|
|
||||||
ensureDir(indexDir),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const contacts = await prisma.contact.findMany({
|
|
||||||
where: { teamId: input.teamId },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
include: {
|
|
||||||
note: { select: { content: true, updatedAt: true } },
|
|
||||||
messages: {
|
|
||||||
select: {
|
|
||||||
kind: true,
|
|
||||||
direction: true,
|
|
||||||
channel: true,
|
|
||||||
content: true,
|
|
||||||
durationSec: true,
|
|
||||||
transcriptJson: true,
|
|
||||||
occurredAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { occurredAt: "asc" },
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
select: { title: true, startsAt: true, endsAt: true, isArchived: true, note: true },
|
|
||||||
orderBy: { startsAt: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contactIndex = [];
|
|
||||||
|
|
||||||
for (const c of contacts) {
|
|
||||||
const contactFile = path.join(contactsDir, `${c.id}.json`);
|
|
||||||
await writeJson(contactFile, {
|
|
||||||
id: c.id,
|
|
||||||
teamId: c.teamId,
|
|
||||||
name: c.name,
|
|
||||||
company: c.company ?? null,
|
|
||||||
country: c.country ?? null,
|
|
||||||
location: c.location ?? null,
|
|
||||||
avatarUrl: c.avatarUrl ?? null,
|
|
||||||
email: c.email ?? null,
|
|
||||||
phone: c.phone ?? null,
|
|
||||||
createdAt: c.createdAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const noteFile = path.join(notesDir, `${c.id}.md`);
|
|
||||||
await fs.writeFile(
|
|
||||||
noteFile,
|
|
||||||
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
const msgFile = path.join(messagesDir, `${c.id}.jsonl`);
|
|
||||||
const msgLines = c.messages.map((m) =>
|
|
||||||
jsonlLine({
|
|
||||||
kind: m.kind,
|
|
||||||
direction: m.direction,
|
|
||||||
channel: m.channel,
|
|
||||||
occurredAt: m.occurredAt,
|
|
||||||
content: m.content,
|
|
||||||
durationSec: m.durationSec ?? null,
|
|
||||||
transcript: m.transcriptJson ?? null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await fs.writeFile(msgFile, msgLines.join(""), "utf8");
|
|
||||||
|
|
||||||
const evFile = path.join(eventsDir, `${c.id}.jsonl`);
|
|
||||||
const evLines = c.events.map((e) =>
|
|
||||||
jsonlLine({
|
|
||||||
title: e.title,
|
|
||||||
startsAt: e.startsAt,
|
|
||||||
endsAt: e.endsAt,
|
|
||||||
isArchived: e.isArchived,
|
|
||||||
note: e.note ?? null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await fs.writeFile(evFile, evLines.join(""), "utf8");
|
|
||||||
|
|
||||||
const lastMessageAt = c.messages.at(-1)?.occurredAt ?? null;
|
|
||||||
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
|
|
||||||
|
|
||||||
contactIndex.push({
|
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
company: c.company ?? null,
|
|
||||||
lastMessageAt,
|
|
||||||
nextEventAt,
|
|
||||||
updatedAt: c.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
|
|
||||||
|
|
||||||
const meta: ExportMeta = { exportedAt: new Date().toISOString(), version: 1 };
|
|
||||||
await writeJson(path.join(tmp, "meta.json"), meta);
|
|
||||||
|
|
||||||
await ensureDir(path.dirname(root));
|
|
||||||
await fs.rm(root, { recursive: true, force: true });
|
|
||||||
await fs.rename(tmp, root);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureDataset(input: { teamId: string; userId: string }) {
|
|
||||||
const root = datasetRoot(input);
|
|
||||||
try {
|
|
||||||
const metaPath = path.join(root, "meta.json");
|
|
||||||
await fs.access(metaPath);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fallthrough
|
|
||||||
}
|
|
||||||
await exportDatasetFromPrismaFor(input);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export function datasetRoot(input: { teamId: string; userId: string }) {
|
|
||||||
// Keep it outside frontend so it can be easily ignored and shared.
|
|
||||||
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", input.teamId, "users", input.userId);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { startTelegramSendWorker } from "../queues/telegramSend";
|
|
||||||
|
|
||||||
export default defineNitroPlugin(() => {
|
|
||||||
// Disabled by default. If you need background processing, wire it explicitly.
|
|
||||||
if (process.env.RUN_QUEUE_WORKER !== "1") return;
|
|
||||||
|
|
||||||
startTelegramSendWorker();
|
|
||||||
});
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +0,0 @@
|
|||||||
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
|
|
||||||
export const OUTBOUND_DELIVERY_QUEUE_NAME = (
|
|
||||||
process.env.SENDER_FLOW_QUEUE_NAME ||
|
|
||||||
process.env.OUTBOUND_DELIVERY_QUEUE_NAME ||
|
|
||||||
"sender.flow"
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
export type OutboundDeliveryJob = {
|
|
||||||
omniMessageId: string;
|
|
||||||
endpoint: string;
|
|
||||||
method?: "POST" | "PUT" | "PATCH";
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
payload: unknown;
|
|
||||||
channel?: string;
|
|
||||||
provider?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function redisConnectionFromEnv(): ConnectionOptions {
|
|
||||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
|
||||||
const parsed = new URL(raw);
|
|
||||||
return {
|
|
||||||
host: parsed.hostname,
|
|
||||||
port: parsed.port ? Number(parsed.port) : 6379,
|
|
||||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
||||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
||||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureHttpUrl(value: string) {
|
|
||||||
const raw = (value ?? "").trim();
|
|
||||||
if (!raw) throw new Error("endpoint is required");
|
|
||||||
const parsed = new URL(raw);
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
||||||
throw new Error(`Unsupported endpoint protocol: ${parsed.protocol}`);
|
|
||||||
}
|
|
||||||
return parsed.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactError(error: unknown) {
|
|
||||||
if (!error) return "unknown_error";
|
|
||||||
if (typeof error === "string") return error;
|
|
||||||
const anyErr = error as any;
|
|
||||||
return String(anyErr?.message ?? anyErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractProviderMessageId(body: unknown): string | null {
|
|
||||||
const obj = body as any;
|
|
||||||
if (!obj || typeof obj !== "object") return null;
|
|
||||||
const candidate =
|
|
||||||
obj?.message_id ??
|
|
||||||
obj?.messageId ??
|
|
||||||
obj?.id ??
|
|
||||||
obj?.result?.message_id ??
|
|
||||||
obj?.result?.id ??
|
|
||||||
null;
|
|
||||||
if (candidate == null) return null;
|
|
||||||
return String(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function outboundDeliveryQueue() {
|
|
||||||
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
|
|
||||||
connection: redisConnectionFromEnv(),
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: { count: 1000 },
|
|
||||||
removeOnFail: { count: 5000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: JobsOptions) {
|
|
||||||
const endpoint = ensureHttpUrl(input.endpoint);
|
|
||||||
const q = outboundDeliveryQueue();
|
|
||||||
|
|
||||||
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
|
|
||||||
// Keep source message in pending before actual send starts.
|
|
||||||
await prisma.omniMessage.update({
|
|
||||||
where: { id: input.omniMessageId },
|
|
||||||
data: {
|
|
||||||
status: "PENDING",
|
|
||||||
rawJson: {
|
|
||||||
queue: {
|
|
||||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
|
||||||
enqueuedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
deliveryRequest: {
|
|
||||||
endpoint,
|
|
||||||
method: input.method ?? "POST",
|
|
||||||
channel: input.channel ?? null,
|
|
||||||
provider: input.provider ?? null,
|
|
||||||
payload,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return q.add("deliver", { ...input, endpoint }, {
|
|
||||||
jobId: `omni-${input.omniMessageId}`,
|
|
||||||
attempts: 12,
|
|
||||||
backoff: { type: "exponential", delay: 1000 },
|
|
||||||
...opts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startOutboundDeliveryWorker() {
|
|
||||||
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
|
|
||||||
OUTBOUND_DELIVERY_QUEUE_NAME,
|
|
||||||
async (job) => {
|
|
||||||
const msg = await prisma.omniMessage.findUnique({
|
|
||||||
where: { id: job.data.omniMessageId },
|
|
||||||
include: { thread: true },
|
|
||||||
});
|
|
||||||
if (!msg) return;
|
|
||||||
|
|
||||||
// Idempotency: if already sent/delivered, do not resend.
|
|
||||||
if ((msg.status === "SENT" || msg.status === "DELIVERED" || msg.status === "READ") && msg.providerMessageId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpoint = ensureHttpUrl(job.data.endpoint);
|
|
||||||
const method = job.data.method ?? "POST";
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
...(job.data.headers ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
|
|
||||||
const requestStartedAt = new Date().toISOString();
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(requestPayload ?? {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await response.text();
|
|
||||||
const responseBody = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerMessageId = extractProviderMessageId(responseBody);
|
|
||||||
await prisma.omniMessage.update({
|
|
||||||
where: { id: msg.id },
|
|
||||||
data: {
|
|
||||||
status: "SENT",
|
|
||||||
providerMessageId,
|
|
||||||
rawJson: {
|
|
||||||
queue: {
|
|
||||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
attemptsMade: job.attemptsMade + 1,
|
|
||||||
},
|
|
||||||
deliveryRequest: {
|
|
||||||
endpoint,
|
|
||||||
method,
|
|
||||||
channel: job.data.channel ?? null,
|
|
||||||
provider: job.data.provider ?? null,
|
|
||||||
startedAt: requestStartedAt,
|
|
||||||
payload: requestPayload,
|
|
||||||
},
|
|
||||||
deliveryResponse: {
|
|
||||||
status: response.status,
|
|
||||||
body: responseBody,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const isLastAttempt =
|
|
||||||
typeof job.opts.attempts === "number" && job.attemptsMade + 1 >= job.opts.attempts;
|
|
||||||
|
|
||||||
if (isLastAttempt) {
|
|
||||||
await prisma.omniMessage.update({
|
|
||||||
where: { id: msg.id },
|
|
||||||
data: {
|
|
||||||
status: "FAILED",
|
|
||||||
rawJson: {
|
|
||||||
queue: {
|
|
||||||
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
|
|
||||||
failedAt: new Date().toISOString(),
|
|
||||||
attemptsMade: job.attemptsMade + 1,
|
|
||||||
},
|
|
||||||
deliveryRequest: {
|
|
||||||
endpoint,
|
|
||||||
method,
|
|
||||||
channel: job.data.channel ?? null,
|
|
||||||
provider: job.data.provider ?? null,
|
|
||||||
startedAt: requestStartedAt,
|
|
||||||
payload: requestPayload,
|
|
||||||
},
|
|
||||||
deliveryError: {
|
|
||||||
message: compactError(error),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ connection: redisConnectionFromEnv() },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { JobsOptions } from "bullmq";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { telegramApiBase, requireTelegramBotToken } from "../utils/telegram";
|
|
||||||
import { enqueueOutboundDelivery, startOutboundDeliveryWorker } from "./outboundDelivery";
|
|
||||||
|
|
||||||
type TelegramSendJob = {
|
|
||||||
omniMessageId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function enqueueTelegramSend(input: TelegramSendJob, opts?: JobsOptions) {
|
|
||||||
const msg = await prisma.omniMessage.findUnique({
|
|
||||||
where: { id: input.omniMessageId },
|
|
||||||
include: { thread: true },
|
|
||||||
});
|
|
||||||
if (!msg) throw new Error(`omni message not found: ${input.omniMessageId}`);
|
|
||||||
if (msg.channel !== "TELEGRAM" || msg.direction !== "OUT") {
|
|
||||||
throw new Error(`Invalid omni message for telegram send: ${msg.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = requireTelegramBotToken();
|
|
||||||
const endpoint = `${telegramApiBase()}/bot${token}/sendMessage`;
|
|
||||||
const payload = {
|
|
||||||
chat_id: msg.thread.externalChatId,
|
|
||||||
text: msg.text,
|
|
||||||
...(msg.thread.businessConnectionId ? { business_connection_id: msg.thread.businessConnectionId } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return enqueueOutboundDelivery(
|
|
||||||
{
|
|
||||||
omniMessageId: msg.id,
|
|
||||||
endpoint,
|
|
||||||
method: "POST",
|
|
||||||
payload,
|
|
||||||
provider: "telegram_business",
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
},
|
|
||||||
opts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startTelegramSendWorker() {
|
|
||||||
return startOutboundDeliveryWorker();
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./outboundDelivery";
|
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { getRedis } from "../utils/redis";
|
|
||||||
|
|
||||||
const worker = startOutboundDeliveryWorker();
|
|
||||||
console.log(`[omni_outbound(legacy-in-frontend)] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`);
|
|
||||||
|
|
||||||
async function shutdown(signal: string) {
|
|
||||||
console.log(`[omni_outbound(legacy-in-frontend)] shutting down by ${signal}`);
|
|
||||||
try {
|
|
||||||
await worker.close();
|
|
||||||
} catch {
|
|
||||||
// ignore shutdown errors
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const redis = getRedis();
|
|
||||||
await redis.quit();
|
|
||||||
} catch {
|
|
||||||
// ignore shutdown errors
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
} catch {
|
|
||||||
// ignore shutdown errors
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void shutdown("SIGINT");
|
|
||||||
});
|
|
||||||
process.on("SIGTERM", () => {
|
|
||||||
void shutdown("SIGTERM");
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { prisma } from "../../utils/prisma";
|
|
||||||
|
|
||||||
const COOKIE_USER = "cf_user";
|
|
||||||
const COOKIE_TEAM = "cf_team";
|
|
||||||
const COOKIE_CONV = "cf_conv";
|
|
||||||
|
|
||||||
const TEAM_POLL_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
const peersByTeam = new Map<string, Set<any>>();
|
|
||||||
const peerTeamById = new Map<string, string>();
|
|
||||||
const lastSignatureByTeam = new Map<string, string>();
|
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
function parseCookies(raw: string | null) {
|
|
||||||
const out = new Map<string, string>();
|
|
||||||
for (const part of String(raw ?? "").split(";")) {
|
|
||||||
const [key, ...rest] = part.trim().split("=");
|
|
||||||
if (!key) continue;
|
|
||||||
const value = rest.join("=");
|
|
||||||
try {
|
|
||||||
out.set(key, decodeURIComponent(value));
|
|
||||||
} catch {
|
|
||||||
out.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachPeerToTeam(peer: any, teamId: string) {
|
|
||||||
if (!peersByTeam.has(teamId)) peersByTeam.set(teamId, new Set());
|
|
||||||
peersByTeam.get(teamId)?.add(peer);
|
|
||||||
peerTeamById.set(String(peer.id), teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detachPeer(peer: any) {
|
|
||||||
const key = String(peer.id);
|
|
||||||
const teamId = peerTeamById.get(key);
|
|
||||||
if (!teamId) return;
|
|
||||||
peerTeamById.delete(key);
|
|
||||||
|
|
||||||
const peers = peersByTeam.get(teamId);
|
|
||||||
if (!peers) return;
|
|
||||||
peers.delete(peer);
|
|
||||||
if (peers.size === 0) {
|
|
||||||
peersByTeam.delete(teamId);
|
|
||||||
lastSignatureByTeam.delete(teamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPollIfIdle() {
|
|
||||||
if (peersByTeam.size > 0 || !pollTimer) return;
|
|
||||||
clearInterval(pollTimer);
|
|
||||||
pollTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateSessionFromPeer(peer: any) {
|
|
||||||
const cookieHeader = peer?.request?.headers?.get?.("cookie") ?? null;
|
|
||||||
const cookies = parseCookies(cookieHeader);
|
|
||||||
|
|
||||||
const userId = String(cookies.get(COOKIE_USER) ?? "").trim();
|
|
||||||
const teamId = String(cookies.get(COOKIE_TEAM) ?? "").trim();
|
|
||||||
const conversationId = String(cookies.get(COOKIE_CONV) ?? "").trim();
|
|
||||||
if (!userId || !teamId || !conversationId) return null;
|
|
||||||
|
|
||||||
const [user, team, conv] = await Promise.all([
|
|
||||||
prisma.user.findUnique({ where: { id: userId }, select: { id: true } }),
|
|
||||||
prisma.team.findUnique({ where: { id: teamId }, select: { id: true } }),
|
|
||||||
prisma.chatConversation.findFirst({
|
|
||||||
where: { id: conversationId, teamId, createdByUserId: userId },
|
|
||||||
select: { id: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!user || !team || !conv) return null;
|
|
||||||
return { teamId };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function computeTeamSignature(teamId: string) {
|
|
||||||
const [omniMessageMax, contactMax, contactMessageMax, telegramConnectionMax] = await Promise.all([
|
|
||||||
prisma.omniMessage.aggregate({
|
|
||||||
where: { teamId },
|
|
||||||
_max: { updatedAt: true },
|
|
||||||
}),
|
|
||||||
prisma.contact.aggregate({
|
|
||||||
where: { teamId },
|
|
||||||
_max: { updatedAt: true },
|
|
||||||
}),
|
|
||||||
prisma.contactMessage.aggregate({
|
|
||||||
where: { contact: { teamId } },
|
|
||||||
_max: { createdAt: true },
|
|
||||||
}),
|
|
||||||
prisma.telegramBusinessConnection.aggregate({
|
|
||||||
where: { teamId },
|
|
||||||
_max: { updatedAt: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
omniMessageMax._max.updatedAt?.toISOString() ?? "",
|
|
||||||
contactMax._max.updatedAt?.toISOString() ?? "",
|
|
||||||
contactMessageMax._max.createdAt?.toISOString() ?? "",
|
|
||||||
telegramConnectionMax._max.updatedAt?.toISOString() ?? "",
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(peer: any, payload: Record<string, unknown>) {
|
|
||||||
try {
|
|
||||||
peer.send(JSON.stringify(payload));
|
|
||||||
} catch {
|
|
||||||
// ignore socket write errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollAndBroadcast() {
|
|
||||||
for (const [teamId, peers] of peersByTeam.entries()) {
|
|
||||||
if (!peers.size) continue;
|
|
||||||
const signature = await computeTeamSignature(teamId);
|
|
||||||
const previous = lastSignatureByTeam.get(teamId);
|
|
||||||
if (signature === previous) continue;
|
|
||||||
|
|
||||||
lastSignatureByTeam.set(teamId, signature);
|
|
||||||
const payload = {
|
|
||||||
type: "dashboard.changed",
|
|
||||||
teamId,
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const peer of peers) {
|
|
||||||
sendJson(peer, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensurePoll() {
|
|
||||||
if (pollTimer) return;
|
|
||||||
pollTimer = setInterval(() => {
|
|
||||||
void pollAndBroadcast();
|
|
||||||
}, TEAM_POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineWebSocketHandler({
|
|
||||||
async open(peer) {
|
|
||||||
const session = await validateSessionFromPeer(peer);
|
|
||||||
if (!session) {
|
|
||||||
peer.close(4401, "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
attachPeerToTeam(peer, session.teamId);
|
|
||||||
ensurePoll();
|
|
||||||
sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() });
|
|
||||||
void pollAndBroadcast();
|
|
||||||
},
|
|
||||||
|
|
||||||
close(peer) {
|
|
||||||
detachPeer(peer);
|
|
||||||
stopPollIfIdle();
|
|
||||||
},
|
|
||||||
|
|
||||||
error(peer) {
|
|
||||||
detachPeer(peer);
|
|
||||||
stopPollIfIdle();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { H3Event } from "h3";
|
|
||||||
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
|
|
||||||
import { prisma } from "./prisma";
|
|
||||||
import { hashPassword } from "./password";
|
|
||||||
|
|
||||||
export type AuthContext = {
|
|
||||||
teamId: string;
|
|
||||||
userId: string;
|
|
||||||
conversationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const COOKIE_USER = "cf_user";
|
|
||||||
const COOKIE_TEAM = "cf_team";
|
|
||||||
const COOKIE_CONV = "cf_conv";
|
|
||||||
|
|
||||||
function cookieOpts() {
|
|
||||||
return {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax" as const,
|
|
||||||
path: "/",
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAuthSession(event: H3Event) {
|
|
||||||
deleteCookie(event, COOKIE_USER, { path: "/" });
|
|
||||||
deleteCookie(event, COOKIE_TEAM, { path: "/" });
|
|
||||||
deleteCookie(event, COOKIE_CONV, { path: "/" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSession(event: H3Event, ctx: AuthContext) {
|
|
||||||
setCookie(event, COOKIE_USER, ctx.userId, cookieOpts());
|
|
||||||
setCookie(event, COOKIE_TEAM, ctx.teamId, cookieOpts());
|
|
||||||
setCookie(event, COOKIE_CONV, ctx.conversationId, cookieOpts());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
|
|
||||||
const cookieUser = getCookie(event, COOKIE_USER)?.trim();
|
|
||||||
const cookieTeam = getCookie(event, COOKIE_TEAM)?.trim();
|
|
||||||
const cookieConv = getCookie(event, COOKIE_CONV)?.trim();
|
|
||||||
|
|
||||||
// Temporary compatibility: allow passing via headers for debugging/dev tools.
|
|
||||||
const hdrTeam = getHeader(event, "x-team-id")?.trim();
|
|
||||||
const hdrUser = getHeader(event, "x-user-id")?.trim();
|
|
||||||
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
|
|
||||||
|
|
||||||
const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv);
|
|
||||||
if (!hasAnySession) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = cookieUser || hdrUser;
|
|
||||||
const teamId = cookieTeam || hdrTeam;
|
|
||||||
const conversationId = cookieConv || hdrConv;
|
|
||||||
|
|
||||||
if (!userId || !teamId || !conversationId) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
|
||||||
const team = await prisma.team.findUnique({ where: { id: teamId } });
|
|
||||||
|
|
||||||
if (!user || !team) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const conv = await prisma.chatConversation.findFirst({
|
|
||||||
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!conv) {
|
|
||||||
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureDemoAuth() {
|
|
||||||
const demoPasswordHash = hashPassword("DemoPass123!");
|
|
||||||
const user = await prisma.user.upsert({
|
|
||||||
where: { id: "demo-user" },
|
|
||||||
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
|
||||||
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
|
|
||||||
});
|
|
||||||
const team = await prisma.team.upsert({
|
|
||||||
where: { id: "demo-team" },
|
|
||||||
update: { name: "Demo Team" },
|
|
||||||
create: { id: "demo-team", name: "Demo Team" },
|
|
||||||
});
|
|
||||||
await prisma.teamMember.upsert({
|
|
||||||
where: { teamId_userId: { teamId: team.id, userId: user.id } },
|
|
||||||
update: {},
|
|
||||||
create: { teamId: team.id, userId: user.id, role: "OWNER" },
|
|
||||||
});
|
|
||||||
const conv = await prisma.chatConversation.upsert({
|
|
||||||
where: { id: `pilot-${team.id}` },
|
|
||||||
update: {},
|
|
||||||
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" },
|
|
||||||
});
|
|
||||||
return { teamId: team.id, userId: user.id, conversationId: conv.id };
|
|
||||||
}
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import type { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
type CalendarSnapshotRow = {
|
|
||||||
id: string;
|
|
||||||
teamId: string;
|
|
||||||
contactId: string | null;
|
|
||||||
title: string;
|
|
||||||
startsAt: string;
|
|
||||||
endsAt: string | null;
|
|
||||||
note: string | null;
|
|
||||||
isArchived: boolean;
|
|
||||||
archiveNote: string | null;
|
|
||||||
archivedAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ContactNoteSnapshotRow = {
|
|
||||||
contactId: string;
|
|
||||||
contactName: string;
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MessageSnapshotRow = {
|
|
||||||
id: string;
|
|
||||||
contactId: string;
|
|
||||||
contactName: string;
|
|
||||||
kind: string;
|
|
||||||
direction: string;
|
|
||||||
channel: string;
|
|
||||||
content: string;
|
|
||||||
durationSec: number | null;
|
|
||||||
occurredAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DealSnapshotRow = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
contactName: string;
|
|
||||||
stage: string;
|
|
||||||
nextStep: string | null;
|
|
||||||
summary: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkspaceDocumentSnapshotRow = {
|
|
||||||
id: string;
|
|
||||||
teamId: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
owner: string;
|
|
||||||
scope: string;
|
|
||||||
summary: string;
|
|
||||||
body: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SnapshotState = {
|
|
||||||
calendarById: Map<string, CalendarSnapshotRow>;
|
|
||||||
noteByContactId: Map<string, ContactNoteSnapshotRow>;
|
|
||||||
messageById: Map<string, MessageSnapshotRow>;
|
|
||||||
dealById: Map<string, DealSnapshotRow>;
|
|
||||||
documentById: Map<string, WorkspaceDocumentSnapshotRow>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChangeItem = {
|
|
||||||
id: string;
|
|
||||||
entity: "calendar_event" | "contact_note" | "message" | "deal" | "workspace_document";
|
|
||||||
entityId: string | null;
|
|
||||||
action: "created" | "updated" | "deleted";
|
|
||||||
title: string;
|
|
||||||
before: string;
|
|
||||||
after: string;
|
|
||||||
undo: UndoOp[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type UndoOp =
|
|
||||||
| { kind: "delete_calendar_event"; id: string }
|
|
||||||
| { kind: "restore_calendar_event"; data: CalendarSnapshotRow }
|
|
||||||
| { kind: "delete_contact_message"; id: string }
|
|
||||||
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
|
|
||||||
| { kind: "restore_contact_note"; contactId: string; content: string | null }
|
|
||||||
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null }
|
|
||||||
| { kind: "delete_workspace_document"; id: string }
|
|
||||||
| { kind: "restore_workspace_document"; data: WorkspaceDocumentSnapshotRow };
|
|
||||||
|
|
||||||
export type ChangeSet = {
|
|
||||||
id: string;
|
|
||||||
status: "pending" | "confirmed" | "rolled_back";
|
|
||||||
createdAt: string;
|
|
||||||
summary: string;
|
|
||||||
items: ChangeItem[];
|
|
||||||
undo: UndoOp[];
|
|
||||||
rolledBackItemIds: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function fmt(val: string | null | undefined) {
|
|
||||||
return (val ?? "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCalendarText(row: CalendarSnapshotRow) {
|
|
||||||
const when = new Date(row.startsAt).toLocaleString("ru-RU");
|
|
||||||
return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMessageText(row: MessageSnapshotRow) {
|
|
||||||
const when = new Date(row.occurredAt).toLocaleString("ru-RU");
|
|
||||||
return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDealText(row: DealSnapshotRow) {
|
|
||||||
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toWorkspaceDocumentText(row: WorkspaceDocumentSnapshotRow) {
|
|
||||||
return `${row.title} · ${row.type} · ${row.owner} · ${row.scope} · ${row.summary}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
|
|
||||||
const [calendar, notes, messages, deals, documents] = await Promise.all([
|
|
||||||
prisma.calendarEvent.findMany({
|
|
||||||
where: { teamId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
teamId: true,
|
|
||||||
contactId: true,
|
|
||||||
title: true,
|
|
||||||
startsAt: true,
|
|
||||||
endsAt: true,
|
|
||||||
note: true,
|
|
||||||
isArchived: true,
|
|
||||||
archiveNote: true,
|
|
||||||
archivedAt: true,
|
|
||||||
},
|
|
||||||
take: 4000,
|
|
||||||
}),
|
|
||||||
prisma.contactNote.findMany({
|
|
||||||
where: { contact: { teamId } },
|
|
||||||
select: { contactId: true, content: true, contact: { select: { name: true } } },
|
|
||||||
take: 4000,
|
|
||||||
}),
|
|
||||||
prisma.contactMessage.findMany({
|
|
||||||
where: { contact: { teamId } },
|
|
||||||
include: { contact: { select: { name: true } } },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
take: 6000,
|
|
||||||
}),
|
|
||||||
prisma.deal.findMany({
|
|
||||||
where: { teamId },
|
|
||||||
include: { contact: { select: { name: true } } },
|
|
||||||
take: 4000,
|
|
||||||
}),
|
|
||||||
prisma.workspaceDocument.findMany({
|
|
||||||
where: { teamId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
teamId: true,
|
|
||||||
title: true,
|
|
||||||
type: true,
|
|
||||||
owner: true,
|
|
||||||
scope: true,
|
|
||||||
summary: true,
|
|
||||||
body: true,
|
|
||||||
},
|
|
||||||
take: 4000,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
calendarById: new Map(
|
|
||||||
calendar.map((row) => [
|
|
||||||
row.id,
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
teamId: row.teamId,
|
|
||||||
contactId: row.contactId ?? null,
|
|
||||||
title: row.title,
|
|
||||||
startsAt: row.startsAt.toISOString(),
|
|
||||||
endsAt: row.endsAt?.toISOString() ?? null,
|
|
||||||
note: row.note ?? null,
|
|
||||||
isArchived: Boolean(row.isArchived),
|
|
||||||
archiveNote: row.archiveNote ?? null,
|
|
||||||
archivedAt: row.archivedAt?.toISOString() ?? null,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
noteByContactId: new Map(
|
|
||||||
notes.map((row) => [
|
|
||||||
row.contactId,
|
|
||||||
{
|
|
||||||
contactId: row.contactId,
|
|
||||||
contactName: row.contact.name,
|
|
||||||
content: row.content,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
messageById: new Map(
|
|
||||||
messages.map((row) => [
|
|
||||||
row.id,
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
contactId: row.contactId,
|
|
||||||
contactName: row.contact.name,
|
|
||||||
kind: row.kind,
|
|
||||||
direction: row.direction,
|
|
||||||
channel: row.channel,
|
|
||||||
content: row.content,
|
|
||||||
durationSec: row.durationSec ?? null,
|
|
||||||
occurredAt: row.occurredAt.toISOString(),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
dealById: new Map(
|
|
||||||
deals.map((row) => [
|
|
||||||
row.id,
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
title: row.title,
|
|
||||||
contactName: row.contact.name,
|
|
||||||
stage: row.stage,
|
|
||||||
nextStep: row.nextStep ?? null,
|
|
||||||
summary: row.summary ?? null,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
documentById: new Map(
|
|
||||||
documents.map((row) => [
|
|
||||||
row.id,
|
|
||||||
{
|
|
||||||
id: row.id,
|
|
||||||
teamId: row.teamId,
|
|
||||||
title: row.title,
|
|
||||||
type: row.type,
|
|
||||||
owner: row.owner,
|
|
||||||
scope: row.scope,
|
|
||||||
summary: row.summary,
|
|
||||||
body: row.body,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null {
|
|
||||||
const items: ChangeItem[] = [];
|
|
||||||
const undo: UndoOp[] = [];
|
|
||||||
const pushItem = (item: Omit<ChangeItem, "id">) => {
|
|
||||||
const next: ChangeItem = { ...item, id: randomUUID() };
|
|
||||||
items.push(next);
|
|
||||||
undo.push(...next.undo);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [id, row] of after.calendarById) {
|
|
||||||
const prev = before.calendarById.get(id);
|
|
||||||
if (!prev) {
|
|
||||||
pushItem({
|
|
||||||
entity: "calendar_event",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "created",
|
|
||||||
title: `Event created: ${row.title}`,
|
|
||||||
before: "",
|
|
||||||
after: toCalendarText(row),
|
|
||||||
undo: [{ kind: "delete_calendar_event", id }],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
prev.title !== row.title ||
|
|
||||||
prev.startsAt !== row.startsAt ||
|
|
||||||
prev.endsAt !== row.endsAt ||
|
|
||||||
fmt(prev.note) !== fmt(row.note) ||
|
|
||||||
prev.isArchived !== row.isArchived ||
|
|
||||||
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
|
|
||||||
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
|
|
||||||
prev.contactId !== row.contactId
|
|
||||||
) {
|
|
||||||
pushItem({
|
|
||||||
entity: "calendar_event",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "updated",
|
|
||||||
title: `Event updated: ${row.title}`,
|
|
||||||
before: toCalendarText(prev),
|
|
||||||
after: toCalendarText(row),
|
|
||||||
undo: [{ kind: "restore_calendar_event", data: prev }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, row] of before.calendarById) {
|
|
||||||
if (after.calendarById.has(id)) continue;
|
|
||||||
pushItem({
|
|
||||||
entity: "calendar_event",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "deleted",
|
|
||||||
title: `Event archived: ${row.title}`,
|
|
||||||
before: toCalendarText(row),
|
|
||||||
after: "",
|
|
||||||
undo: [{ kind: "restore_calendar_event", data: row }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [contactId, row] of after.noteByContactId) {
|
|
||||||
const prev = before.noteByContactId.get(contactId);
|
|
||||||
if (!prev) {
|
|
||||||
pushItem({
|
|
||||||
entity: "contact_note",
|
|
||||||
entityId: contactId,
|
|
||||||
action: "created",
|
|
||||||
title: `Summary added: ${row.contactName}`,
|
|
||||||
before: "",
|
|
||||||
after: row.content,
|
|
||||||
undo: [{ kind: "restore_contact_note", contactId, content: null }],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (prev.content !== row.content) {
|
|
||||||
pushItem({
|
|
||||||
entity: "contact_note",
|
|
||||||
entityId: contactId,
|
|
||||||
action: "updated",
|
|
||||||
title: `Summary updated: ${row.contactName}`,
|
|
||||||
before: prev.content,
|
|
||||||
after: row.content,
|
|
||||||
undo: [{ kind: "restore_contact_note", contactId, content: prev.content }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [contactId, row] of before.noteByContactId) {
|
|
||||||
if (after.noteByContactId.has(contactId)) continue;
|
|
||||||
pushItem({
|
|
||||||
entity: "contact_note",
|
|
||||||
entityId: contactId,
|
|
||||||
action: "deleted",
|
|
||||||
title: `Summary cleared: ${row.contactName}`,
|
|
||||||
before: row.content,
|
|
||||||
after: "",
|
|
||||||
undo: [{ kind: "restore_contact_note", contactId, content: row.content }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, row] of after.messageById) {
|
|
||||||
if (before.messageById.has(id)) continue;
|
|
||||||
pushItem({
|
|
||||||
entity: "message",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "created",
|
|
||||||
title: `Message created: ${row.contactName}`,
|
|
||||||
before: "",
|
|
||||||
after: toMessageText(row),
|
|
||||||
undo: [{ kind: "delete_contact_message", id }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, row] of after.dealById) {
|
|
||||||
const prev = before.dealById.get(id);
|
|
||||||
if (!prev) continue;
|
|
||||||
if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) {
|
|
||||||
pushItem({
|
|
||||||
entity: "deal",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "updated",
|
|
||||||
title: `Deal updated: ${row.title}`,
|
|
||||||
before: toDealText(prev),
|
|
||||||
after: toDealText(row),
|
|
||||||
undo: [
|
|
||||||
{
|
|
||||||
kind: "restore_deal",
|
|
||||||
id,
|
|
||||||
stage: prev.stage,
|
|
||||||
nextStep: prev.nextStep,
|
|
||||||
summary: prev.summary,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, row] of after.documentById) {
|
|
||||||
const prev = before.documentById.get(id);
|
|
||||||
if (!prev) {
|
|
||||||
pushItem({
|
|
||||||
entity: "workspace_document",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "created",
|
|
||||||
title: `Document created: ${row.title}`,
|
|
||||||
before: "",
|
|
||||||
after: toWorkspaceDocumentText(row),
|
|
||||||
undo: [{ kind: "delete_workspace_document", id }],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
prev.title !== row.title ||
|
|
||||||
prev.type !== row.type ||
|
|
||||||
prev.owner !== row.owner ||
|
|
||||||
prev.scope !== row.scope ||
|
|
||||||
prev.summary !== row.summary ||
|
|
||||||
prev.body !== row.body
|
|
||||||
) {
|
|
||||||
pushItem({
|
|
||||||
entity: "workspace_document",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "updated",
|
|
||||||
title: `Document updated: ${row.title}`,
|
|
||||||
before: toWorkspaceDocumentText(prev),
|
|
||||||
after: toWorkspaceDocumentText(row),
|
|
||||||
undo: [{ kind: "restore_workspace_document", data: prev }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, row] of before.documentById) {
|
|
||||||
if (after.documentById.has(id)) continue;
|
|
||||||
pushItem({
|
|
||||||
entity: "workspace_document",
|
|
||||||
entityId: row.id,
|
|
||||||
action: "deleted",
|
|
||||||
title: `Document deleted: ${row.title}`,
|
|
||||||
before: toWorkspaceDocumentText(row),
|
|
||||||
after: "",
|
|
||||||
undo: [{ kind: "restore_workspace_document", data: row }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
const created = items.filter((x) => x.action === "created").length;
|
|
||||||
const updated = items.filter((x) => x.action === "updated").length;
|
|
||||||
const deleted = items.filter((x) => x.action === "deleted").length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: randomUUID(),
|
|
||||||
status: "pending",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`,
|
|
||||||
items,
|
|
||||||
undo,
|
|
||||||
rolledBackItemIds: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyUndoOps(prisma: PrismaClient, teamId: string, undoOps: UndoOp[]) {
|
|
||||||
const ops = [...undoOps].reverse();
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
for (const op of ops) {
|
|
||||||
if (op.kind === "delete_calendar_event") {
|
|
||||||
await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "restore_calendar_event") {
|
|
||||||
const row = op.data;
|
|
||||||
await tx.calendarEvent.upsert({
|
|
||||||
where: { id: row.id },
|
|
||||||
update: {
|
|
||||||
teamId: row.teamId,
|
|
||||||
contactId: row.contactId,
|
|
||||||
title: row.title,
|
|
||||||
startsAt: new Date(row.startsAt),
|
|
||||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
|
||||||
note: row.note,
|
|
||||||
isArchived: row.isArchived,
|
|
||||||
archiveNote: row.archiveNote,
|
|
||||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: row.id,
|
|
||||||
teamId: row.teamId,
|
|
||||||
contactId: row.contactId,
|
|
||||||
title: row.title,
|
|
||||||
startsAt: new Date(row.startsAt),
|
|
||||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
|
||||||
note: row.note,
|
|
||||||
isArchived: row.isArchived,
|
|
||||||
archiveNote: row.archiveNote,
|
|
||||||
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "delete_contact_message") {
|
|
||||||
await tx.contactMessage.deleteMany({ where: { id: op.id } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "restore_contact_message") {
|
|
||||||
const row = op.data;
|
|
||||||
await tx.contactMessage.upsert({
|
|
||||||
where: { id: row.id },
|
|
||||||
update: {
|
|
||||||
contactId: row.contactId,
|
|
||||||
kind: row.kind as any,
|
|
||||||
direction: row.direction as any,
|
|
||||||
channel: row.channel as any,
|
|
||||||
content: row.content,
|
|
||||||
durationSec: row.durationSec,
|
|
||||||
occurredAt: new Date(row.occurredAt),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: row.id,
|
|
||||||
contactId: row.contactId,
|
|
||||||
kind: row.kind as any,
|
|
||||||
direction: row.direction as any,
|
|
||||||
channel: row.channel as any,
|
|
||||||
content: row.content,
|
|
||||||
durationSec: row.durationSec,
|
|
||||||
occurredAt: new Date(row.occurredAt),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "restore_contact_note") {
|
|
||||||
const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } });
|
|
||||||
if (!contact) continue;
|
|
||||||
if (op.content === null) {
|
|
||||||
await tx.contactNote.deleteMany({ where: { contactId: op.contactId } });
|
|
||||||
} else {
|
|
||||||
await tx.contactNote.upsert({
|
|
||||||
where: { contactId: op.contactId },
|
|
||||||
update: { content: op.content },
|
|
||||||
create: { contactId: op.contactId, content: op.content },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "restore_deal") {
|
|
||||||
await tx.deal.updateMany({
|
|
||||||
where: { id: op.id, teamId },
|
|
||||||
data: {
|
|
||||||
stage: op.stage,
|
|
||||||
nextStep: op.nextStep,
|
|
||||||
summary: op.summary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "delete_workspace_document") {
|
|
||||||
await tx.workspaceDocument.deleteMany({ where: { id: op.id, teamId } });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (op.kind === "restore_workspace_document") {
|
|
||||||
const row = op.data;
|
|
||||||
await tx.workspaceDocument.upsert({
|
|
||||||
where: { id: row.id },
|
|
||||||
update: {
|
|
||||||
teamId: row.teamId,
|
|
||||||
title: row.title,
|
|
||||||
type: row.type as any,
|
|
||||||
owner: row.owner,
|
|
||||||
scope: row.scope,
|
|
||||||
summary: row.summary,
|
|
||||||
body: row.body,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: row.id,
|
|
||||||
teamId: row.teamId,
|
|
||||||
title: row.title,
|
|
||||||
type: row.type as any,
|
|
||||||
owner: row.owner,
|
|
||||||
scope: row.scope,
|
|
||||||
summary: row.summary,
|
|
||||||
body: row.body,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) {
|
|
||||||
await applyUndoOps(prisma, teamId, changeSet.undo);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rollbackChangeSetItems(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
teamId: string,
|
|
||||||
changeSet: ChangeSet,
|
|
||||||
itemIds: string[],
|
|
||||||
) {
|
|
||||||
const wanted = new Set(itemIds.filter(Boolean));
|
|
||||||
if (!wanted.size) return;
|
|
||||||
|
|
||||||
const itemUndoOps = changeSet.items
|
|
||||||
.filter((item) => wanted.has(item.id))
|
|
||||||
.flatMap((item) => (Array.isArray(item.undo) ? item.undo : []));
|
|
||||||
|
|
||||||
if (itemUndoOps.length > 0) {
|
|
||||||
await applyUndoOps(prisma, teamId, itemUndoOps);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy fallback for old change sets without per-item undo.
|
|
||||||
if (wanted.size >= changeSet.items.length && Array.isArray(changeSet.undo) && changeSet.undo.length > 0) {
|
|
||||||
await applyUndoOps(prisma, teamId, changeSet.undo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Langfuse } from "langfuse";
|
|
||||||
|
|
||||||
let client: Langfuse | null = null;
|
|
||||||
|
|
||||||
function isTruthy(value: string | undefined) {
|
|
||||||
const v = (value ?? "").trim().toLowerCase();
|
|
||||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLangfuseEnabled() {
|
|
||||||
const enabledRaw = process.env.LANGFUSE_ENABLED;
|
|
||||||
if (enabledRaw && !isTruthy(enabledRaw)) return false;
|
|
||||||
return Boolean((process.env.LANGFUSE_PUBLIC_KEY ?? "").trim() && (process.env.LANGFUSE_SECRET_KEY ?? "").trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLangfuseClient() {
|
|
||||||
if (!isLangfuseEnabled()) return null;
|
|
||||||
if (client) return client;
|
|
||||||
|
|
||||||
client = new Langfuse({
|
|
||||||
publicKey: (process.env.LANGFUSE_PUBLIC_KEY ?? "").trim(),
|
|
||||||
secretKey: (process.env.LANGFUSE_SECRET_KEY ?? "").trim(),
|
|
||||||
baseUrl: (process.env.LANGFUSE_BASE_URL ?? "http://langfuse-web:3000").trim(),
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
||||||
|
|
||||||
const SCRYPT_KEY_LENGTH = 64;
|
|
||||||
|
|
||||||
export function normalizePhone(raw: string) {
|
|
||||||
const trimmed = (raw ?? "").trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
const hasPlus = trimmed.startsWith("+");
|
|
||||||
const digits = trimmed.replace(/\D/g, "");
|
|
||||||
if (!digits) return "";
|
|
||||||
return `${hasPlus ? "+" : ""}${digits}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashPassword(password: string) {
|
|
||||||
const salt = randomBytes(16).toString("base64url");
|
|
||||||
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
|
|
||||||
return `scrypt$${salt}$${digest}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyPassword(password: string, encodedHash: string) {
|
|
||||||
const [algo, salt, digest] = (encodedHash ?? "").split("$");
|
|
||||||
if (algo !== "scrypt" || !salt || !digest) return false;
|
|
||||||
|
|
||||||
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
|
|
||||||
const expected = Buffer.from(digest, "base64url");
|
|
||||||
if (actual.byteLength !== expected.byteLength) return false;
|
|
||||||
|
|
||||||
return timingSafeEqual(actual, expected);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __prisma: PrismaClient | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma =
|
|
||||||
globalThis.__prisma ??
|
|
||||||
new PrismaClient({
|
|
||||||
log: ["error", "warn"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
globalThis.__prisma = prisma;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import Redis from "ioredis";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __redis: Redis | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRedis() {
|
|
||||||
if (globalThis.__redis) return globalThis.__redis;
|
|
||||||
|
|
||||||
const url = process.env.REDIS_URL || "redis://localhost:6379";
|
|
||||||
const client = new Redis(url, {
|
|
||||||
maxRetriesPerRequest: null, // recommended for BullMQ
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
globalThis.__redis = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export type TelegramUpdate = Record<string, any>;
|
|
||||||
|
|
||||||
export function telegramApiBase() {
|
|
||||||
return process.env.TELEGRAM_API_BASE || "https://api.telegram.org";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireTelegramBotToken() {
|
|
||||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
||||||
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function telegramBotApi<T>(method: string, body: unknown): Promise<T> {
|
|
||||||
const token = requireTelegramBotToken();
|
|
||||||
const res = await fetch(`${telegramApiBase()}/bot${token}/${method}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = (await res.json().catch(() => null)) as any;
|
|
||||||
if (!res.ok || !json?.ok) {
|
|
||||||
const desc = json?.description || `HTTP ${res.status}`;
|
|
||||||
throw new Error(`Telegram API ${method} failed: ${desc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.result as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
|
|
||||||
export type LinkTokenPayloadV1 = {
|
|
||||||
v: 1;
|
|
||||||
nonce: string;
|
|
||||||
exp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60);
|
|
||||||
|
|
||||||
export function requireBotUsername() {
|
|
||||||
const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, "");
|
|
||||||
if (!botUsername) {
|
|
||||||
throw createError({ statusCode: 500, statusMessage: "TELEGRAM_BOT_USERNAME is required" });
|
|
||||||
}
|
|
||||||
return botUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function issueLinkToken(input: { teamId: string; userId: string }) {
|
|
||||||
void input;
|
|
||||||
// Telegram deep-link `start` parameter is limited, keep token very short.
|
|
||||||
const token = randomBytes(12).toString("hex");
|
|
||||||
const payload: LinkTokenPayloadV1 = {
|
|
||||||
v: 1,
|
|
||||||
nonce: token,
|
|
||||||
exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC),
|
|
||||||
};
|
|
||||||
return { token, payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null {
|
|
||||||
const raw = String(token || "").trim();
|
|
||||||
if (!/^[a-f0-9]{24}$/.test(raw)) return null;
|
|
||||||
return {
|
|
||||||
v: 1,
|
|
||||||
nonce: raw,
|
|
||||||
exp: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractLinkTokenFromStartText(text: string) {
|
|
||||||
const trimmed = String(text || "").trim();
|
|
||||||
if (!trimmed.startsWith("/start")) return null;
|
|
||||||
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
const arg = parts[1] || "";
|
|
||||||
if (!arg.startsWith("link_")) return null;
|
|
||||||
return arg.slice("link_".length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTelegramStartUrl(token: string) {
|
|
||||||
const botUsername = requireBotUsername();
|
|
||||||
return `https://t.me/${botUsername}?start=link_${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTelegramChatIdFromUpdate(update: any): string | null {
|
|
||||||
const candidates = [
|
|
||||||
update?.message?.chat?.id,
|
|
||||||
update?.business_message?.chat?.id,
|
|
||||||
update?.edited_business_message?.chat?.id,
|
|
||||||
update?.business_connection?.user_chat_id,
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
if (c == null) continue;
|
|
||||||
const v = String(c).trim();
|
|
||||||
if (v) return v;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBusinessConnectionFromUpdate(update: any): {
|
|
||||||
id: string;
|
|
||||||
userChatId: string | null;
|
|
||||||
isEnabled: boolean | null;
|
|
||||||
canReply: boolean | null;
|
|
||||||
raw: any;
|
|
||||||
} | null {
|
|
||||||
const bc = (update?.business_connection ?? null) as any;
|
|
||||||
if (!bc || typeof bc !== "object") return null;
|
|
||||||
|
|
||||||
const id = String(bc.id ?? "").trim();
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
const userChatId = bc.user_chat_id != null ? String(bc.user_chat_id) : null;
|
|
||||||
const isEnabled = typeof bc.is_enabled === "boolean" ? bc.is_enabled : null;
|
|
||||||
const canReply = typeof bc.can_reply === "boolean"
|
|
||||||
? bc.can_reply
|
|
||||||
: typeof bc.rights?.can_reply === "boolean"
|
|
||||||
? bc.rights.can_reply
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
userChatId,
|
|
||||||
isEnabled,
|
|
||||||
canReply,
|
|
||||||
raw: bc,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
type WhisperTranscribeInput = {
|
|
||||||
samples: Float32Array;
|
|
||||||
sampleRate: number;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
let whisperPipelinePromise: Promise<any> | null = null;
|
|
||||||
let transformersPromise: Promise<any> | null = null;
|
|
||||||
|
|
||||||
function getWhisperModelId() {
|
|
||||||
return (process.env.CF_WHISPER_MODEL ?? "Xenova/whisper-small").trim() || "Xenova/whisper-small";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWhisperLanguage() {
|
|
||||||
const value = (process.env.CF_WHISPER_LANGUAGE ?? "ru").trim();
|
|
||||||
return value || "ru";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWhisperPipeline() {
|
|
||||||
if (!transformersPromise) {
|
|
||||||
transformersPromise = import("@xenova/transformers");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { env, pipeline } = await transformersPromise;
|
|
||||||
|
|
||||||
if (!whisperPipelinePromise) {
|
|
||||||
env.allowRemoteModels = true;
|
|
||||||
env.allowLocalModels = true;
|
|
||||||
env.cacheDir = "/app/.data/transformers";
|
|
||||||
|
|
||||||
const modelId = getWhisperModelId();
|
|
||||||
whisperPipelinePromise = pipeline("automatic-speech-recognition", modelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return whisperPipelinePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function transcribeWithWhisper(input: WhisperTranscribeInput) {
|
|
||||||
const transcriber = (await getWhisperPipeline()) as any;
|
|
||||||
const result = await transcriber(
|
|
||||||
input.samples,
|
|
||||||
{
|
|
||||||
sampling_rate: input.sampleRate,
|
|
||||||
language: (input.language ?? getWhisperLanguage()) || "ru",
|
|
||||||
task: "transcribe",
|
|
||||||
chunk_length_s: 20,
|
|
||||||
stride_length_s: 5,
|
|
||||||
return_timestamps: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const text = String((result as any)?.text ?? "").trim();
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.nuxt/tsconfig.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
34
hatchet/README.md
Normal file
34
hatchet/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Hatchet (Dokploy)
|
||||||
|
|
||||||
|
Compose-стек для self-hosted Hatchet, перенесенный из соседнего проекта `gl` и адаптированный под ENV.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- `docker-compose.yml` — сервисы Hatchet (Postgres, RabbitMQ, migration, setup-config, engine, dashboard).
|
||||||
|
|
||||||
|
## Обязательные ENV (Dokploy UI)
|
||||||
|
|
||||||
|
- `HATCHET_POSTGRES_USER` (default: `hatchet`)
|
||||||
|
- `HATCHET_POSTGRES_PASSWORD` (default: `hatchet`)
|
||||||
|
- `HATCHET_POSTGRES_DB` (default: `hatchet`)
|
||||||
|
- `HATCHET_DATABASE_URL` (default: `postgres://hatchet:hatchet@postgres:5432/hatchet`)
|
||||||
|
- `HATCHET_RABBITMQ_USER` (default: `user`)
|
||||||
|
- `HATCHET_RABBITMQ_PASSWORD` (default: `password`)
|
||||||
|
- `HATCHET_RABBITMQ_URL` (default: `amqp://user:password@rabbitmq:5672/`)
|
||||||
|
- `HATCHET_SERVER_AUTH_COOKIE_DOMAIN` (например, `hatchet.<ваш-домен>`)
|
||||||
|
- `HATCHET_SERVER_AUTH_COOKIE_INSECURE` (`t`/`f`)
|
||||||
|
- `HATCHET_SERVER_GRPC_INSECURE` (`t`/`f`)
|
||||||
|
- `HATCHET_SERVER_GRPC_BROADCAST_ADDRESS` (например, `hatchet-engine:7070` внутри сети)
|
||||||
|
|
||||||
|
## ENV для приложений-воркеров (Node SDK)
|
||||||
|
|
||||||
|
- `HATCHET_CLIENT_TOKEN` — токен клиента из Hatchet.
|
||||||
|
- `HATCHET_CLIENT_TLS_STRATEGY` — для self-host без TLS: `none`.
|
||||||
|
- `HATCHET_CLIENT_HOST_PORT` — gRPC адрес (например, `hatchet-engine:7070` в одной Docker-сети).
|
||||||
|
- `HATCHET_CLIENT_API_URL` — URL API Hatchet dashboard/api.
|
||||||
|
|
||||||
|
## Развертывание
|
||||||
|
|
||||||
|
Сервис описан в `deploy-map.toml` как:
|
||||||
|
|
||||||
|
`hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }`
|
||||||
121
hatchet/docker-compose.yml
Normal file
121
hatchet/docker-compose.yml
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15.6
|
||||||
|
command: postgres -c "max_connections=1000"
|
||||||
|
restart: always
|
||||||
|
hostname: postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${HATCHET_POSTGRES_USER:-hatchet}
|
||||||
|
POSTGRES_PASSWORD: ${HATCHET_POSTGRES_PASSWORD:-hatchet}
|
||||||
|
POSTGRES_DB: ${HATCHET_POSTGRES_DB:-hatchet}
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
volumes:
|
||||||
|
- hatchet_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d ${HATCHET_POSTGRES_DB:-hatchet} -U ${HATCHET_POSTGRES_USER:-hatchet}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management
|
||||||
|
hostname: rabbitmq
|
||||||
|
expose:
|
||||||
|
- "5672"
|
||||||
|
- "15672"
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: ${HATCHET_RABBITMQ_USER:-user}
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${HATCHET_RABBITMQ_PASSWORD:-password}
|
||||||
|
volumes:
|
||||||
|
- hatchet_rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
- hatchet_rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmqctl", "status"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
migration:
|
||||||
|
image: ghcr.io/hatchet-dev/hatchet/hatchet-migrate:latest
|
||||||
|
command: /hatchet/hatchet-migrate
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
setup-config:
|
||||||
|
image: ghcr.io/hatchet-dev/hatchet/hatchet-admin:latest
|
||||||
|
command: /hatchet/hatchet-admin quickstart --skip certs --generated-config-dir /hatchet/config --overwrite=false
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||||
|
SERVER_MSGQUEUE_RABBITMQ_URL: ${HATCHET_RABBITMQ_URL:-amqp://user:password@rabbitmq:5672/}
|
||||||
|
SERVER_AUTH_COOKIE_DOMAIN: ${HATCHET_SERVER_AUTH_COOKIE_DOMAIN:-hatchet.local}
|
||||||
|
SERVER_AUTH_COOKIE_INSECURE: ${HATCHET_SERVER_AUTH_COOKIE_INSECURE:-t}
|
||||||
|
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||||
|
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||||
|
SERVER_GRPC_BROADCAST_ADDRESS: ${HATCHET_SERVER_GRPC_BROADCAST_ADDRESS:-hatchet-engine:7070}
|
||||||
|
SERVER_DEFAULT_ENGINE_VERSION: V1
|
||||||
|
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet-engine:7070
|
||||||
|
volumes:
|
||||||
|
- hatchet_certs:/hatchet/certs
|
||||||
|
- hatchet_config:/hatchet/config
|
||||||
|
depends_on:
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
hatchet-engine:
|
||||||
|
image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:latest
|
||||||
|
command: /hatchet/hatchet-engine --config /hatchet/config
|
||||||
|
restart: on-failure
|
||||||
|
depends_on:
|
||||||
|
setup-config:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
expose:
|
||||||
|
- "7070"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||||
|
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
|
||||||
|
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
|
||||||
|
volumes:
|
||||||
|
- hatchet_certs:/hatchet/certs
|
||||||
|
- hatchet_config:/hatchet/config
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- dokploy-network
|
||||||
|
|
||||||
|
hatchet-dashboard:
|
||||||
|
image: ghcr.io/hatchet-dev/hatchet/hatchet-dashboard:latest
|
||||||
|
command: sh ./entrypoint.sh --config /hatchet/config
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
restart: on-failure
|
||||||
|
depends_on:
|
||||||
|
setup-config:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
|
||||||
|
volumes:
|
||||||
|
- hatchet_certs:/hatchet/certs
|
||||||
|
- hatchet_config:/hatchet/config
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hatchet_postgres_data:
|
||||||
|
hatchet_rabbitmq_data:
|
||||||
|
hatchet_rabbitmq.conf:
|
||||||
|
hatchet_config:
|
||||||
|
hatchet_certs:
|
||||||
Submodule instructions updated: da748fa24c...19bbaf3e08
@@ -1,11 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
COPY prisma ./prisma
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# omni_chat
|
|
||||||
|
|
||||||
Изолированный сервис chat-core (домен диалогов).
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
|
|
||||||
- потребляет входящие события из `receiver.flow`;
|
|
||||||
- применяет бизнес-логику диалогов;
|
|
||||||
- публикует исходящие команды в `sender.flow`.
|
|
||||||
|
|
||||||
Текущий шаг: выделен отдельный сервисный контур и health endpoint.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
- `GET /health`
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
- `PORT` (default: `8090`)
|
|
||||||
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
|
|
||||||
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
|
|
||||||
|
|
||||||
## Prisma policy
|
|
||||||
|
|
||||||
- Источник схемы: `frontend/prisma/schema.prisma`.
|
|
||||||
- Локальная копия в `omni_chat/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
|
||||||
- Миграции/`db push` выполняются только в `frontend`.
|
|
||||||
1334
omni_chat/package-lock.json
generated
1334
omni_chat/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crm-omni-chat",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "tsx src/index.ts",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"postinstall": "node ./node_modules/prisma/build/index.js generate --schema ./prisma/schema.prisma"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.13.9",
|
|
||||||
"prisma": "^6.16.1",
|
|
||||||
"tsx": "^4.20.5",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/client": "^6.16.1",
|
|
||||||
"bullmq": "^5.70.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TeamRole {
|
|
||||||
OWNER
|
|
||||||
MEMBER
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageDirection {
|
|
||||||
IN
|
|
||||||
OUT
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MessageChannel {
|
|
||||||
TELEGRAM
|
|
||||||
WHATSAPP
|
|
||||||
INSTAGRAM
|
|
||||||
PHONE
|
|
||||||
EMAIL
|
|
||||||
INTERNAL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ContactMessageKind {
|
|
||||||
MESSAGE
|
|
||||||
CALL
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ChatRole {
|
|
||||||
USER
|
|
||||||
ASSISTANT
|
|
||||||
SYSTEM
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OmniMessageStatus {
|
|
||||||
PENDING
|
|
||||||
SENT
|
|
||||||
FAILED
|
|
||||||
DELIVERED
|
|
||||||
READ
|
|
||||||
}
|
|
||||||
|
|
||||||
enum FeedCardDecision {
|
|
||||||
PENDING
|
|
||||||
ACCEPTED
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WorkspaceDocumentType {
|
|
||||||
Regulation
|
|
||||||
Playbook
|
|
||||||
Policy
|
|
||||||
Template
|
|
||||||
}
|
|
||||||
|
|
||||||
model Team {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
members TeamMember[]
|
|
||||||
contacts Contact[]
|
|
||||||
calendarEvents CalendarEvent[]
|
|
||||||
deals Deal[]
|
|
||||||
conversations ChatConversation[]
|
|
||||||
chatMessages ChatMessage[]
|
|
||||||
|
|
||||||
omniThreads OmniThread[]
|
|
||||||
omniMessages OmniMessage[]
|
|
||||||
omniIdentities OmniContactIdentity[]
|
|
||||||
telegramBusinessConnections TelegramBusinessConnection[]
|
|
||||||
|
|
||||||
feedCards FeedCard[]
|
|
||||||
contactPins ContactPin[]
|
|
||||||
documents WorkspaceDocument[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
phone String @unique
|
|
||||||
passwordHash String
|
|
||||||
email String? @unique
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
memberships TeamMember[]
|
|
||||||
conversations ChatConversation[] @relation("ConversationCreator")
|
|
||||||
chatMessages ChatMessage[] @relation("ChatAuthor")
|
|
||||||
}
|
|
||||||
|
|
||||||
model TeamMember {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
userId String
|
|
||||||
role TeamRole @default(MEMBER)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, userId])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Contact {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
name String
|
|
||||||
company String?
|
|
||||||
country String?
|
|
||||||
location String?
|
|
||||||
avatarUrl String?
|
|
||||||
email String?
|
|
||||||
phone String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
note ContactNote?
|
|
||||||
messages ContactMessage[]
|
|
||||||
events CalendarEvent[]
|
|
||||||
deals Deal[]
|
|
||||||
feedCards FeedCard[]
|
|
||||||
pins ContactPin[]
|
|
||||||
|
|
||||||
omniThreads OmniThread[]
|
|
||||||
omniMessages OmniMessage[]
|
|
||||||
omniIdentities OmniContactIdentity[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactNote {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
contactId String @unique
|
|
||||||
content String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
contactId String
|
|
||||||
kind ContactMessageKind @default(MESSAGE)
|
|
||||||
direction MessageDirection
|
|
||||||
channel MessageChannel
|
|
||||||
content String
|
|
||||||
audioUrl String?
|
|
||||||
durationSec Int?
|
|
||||||
transcriptJson Json?
|
|
||||||
occurredAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([contactId, occurredAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniContactIdentity {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
channel MessageChannel
|
|
||||||
externalId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, channel, externalId])
|
|
||||||
@@index([contactId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniThread {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
channel MessageChannel
|
|
||||||
externalChatId String
|
|
||||||
businessConnectionId String?
|
|
||||||
title String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
messages OmniMessage[]
|
|
||||||
|
|
||||||
@@unique([teamId, channel, externalChatId, businessConnectionId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model OmniMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
threadId String
|
|
||||||
direction MessageDirection
|
|
||||||
channel MessageChannel
|
|
||||||
status OmniMessageStatus @default(PENDING)
|
|
||||||
text String
|
|
||||||
providerMessageId String?
|
|
||||||
providerUpdateId String?
|
|
||||||
rawJson Json?
|
|
||||||
occurredAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([threadId, providerMessageId])
|
|
||||||
@@index([teamId, occurredAt])
|
|
||||||
@@index([threadId, occurredAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model TelegramBusinessConnection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
businessConnectionId String
|
|
||||||
isEnabled Boolean?
|
|
||||||
canReply Boolean?
|
|
||||||
rawJson Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([teamId, businessConnectionId])
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CalendarEvent {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String?
|
|
||||||
title String
|
|
||||||
startsAt DateTime
|
|
||||||
endsAt DateTime?
|
|
||||||
note String?
|
|
||||||
isArchived Boolean @default(false)
|
|
||||||
archiveNote String?
|
|
||||||
archivedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([startsAt])
|
|
||||||
@@index([contactId, startsAt])
|
|
||||||
@@index([teamId, startsAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Deal {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
title String
|
|
||||||
stage String
|
|
||||||
amount Int?
|
|
||||||
nextStep String?
|
|
||||||
summary String?
|
|
||||||
currentStepId String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
steps DealStep[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
@@index([currentStepId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model DealStep {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
dealId String
|
|
||||||
title String
|
|
||||||
description String?
|
|
||||||
status String @default("todo")
|
|
||||||
dueAt DateTime?
|
|
||||||
order Int @default(0)
|
|
||||||
completedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([dealId, order])
|
|
||||||
@@index([status, dueAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatConversation {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
createdByUserId String
|
|
||||||
title String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
|
||||||
messages ChatMessage[]
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([createdByUserId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ChatMessage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
conversationId String
|
|
||||||
authorUserId String?
|
|
||||||
role ChatRole
|
|
||||||
text String
|
|
||||||
planJson Json?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
|
||||||
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([teamId, createdAt])
|
|
||||||
@@index([conversationId, createdAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model FeedCard {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String?
|
|
||||||
happenedAt DateTime
|
|
||||||
text String
|
|
||||||
proposalJson Json
|
|
||||||
decision FeedCardDecision @default(PENDING)
|
|
||||||
decisionNote String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([teamId, happenedAt])
|
|
||||||
@@index([contactId, happenedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ContactPin {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
contactId String
|
|
||||||
text String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
@@index([contactId, updatedAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
model WorkspaceDocument {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
teamId String
|
|
||||||
title String
|
|
||||||
type WorkspaceDocumentType
|
|
||||||
owner String
|
|
||||||
scope String
|
|
||||||
summary String
|
|
||||||
body String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([teamId, updatedAt])
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createServer } from "node:http";
|
|
||||||
import { closeReceiverWorker, RECEIVER_FLOW_QUEUE_NAME, receiverQueue, startReceiverWorker } from "./worker";
|
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 8090);
|
|
||||||
const service = "omni_chat";
|
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
if (req.method === "GET" && req.url === "/health") {
|
|
||||||
const q = receiverQueue();
|
|
||||||
const counts = await q.getJobCounts("wait", "active", "failed", "completed", "delayed");
|
|
||||||
await q.close();
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
service,
|
|
||||||
receiverFlow: RECEIVER_FLOW_QUEUE_NAME,
|
|
||||||
senderFlow: process.env.SENDER_FLOW_QUEUE_NAME || "sender.flow",
|
|
||||||
queue: counts,
|
|
||||||
now: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(JSON.stringify({ ok: false, error: "not_found" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
startReceiverWorker();
|
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
|
||||||
console.log(`[omni_chat] listening on :${port}`);
|
|
||||||
console.log(`[omni_chat] receiver worker started for queue ${RECEIVER_FLOW_QUEUE_NAME}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function shutdown(signal: string) {
|
|
||||||
console.log(`[omni_chat] shutting down by ${signal}`);
|
|
||||||
try {
|
|
||||||
await closeReceiverWorker();
|
|
||||||
} finally {
|
|
||||||
server.close(() => process.exit(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void shutdown("SIGINT");
|
|
||||||
});
|
|
||||||
process.on("SIGTERM", () => {
|
|
||||||
void shutdown("SIGTERM");
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __omniChatPrisma: PrismaClient | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prisma =
|
|
||||||
globalThis.__omniChatPrisma ??
|
|
||||||
new PrismaClient({
|
|
||||||
log: ["error", "warn"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
globalThis.__omniChatPrisma = prisma;
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
import { Queue, Worker, type ConnectionOptions, type Job } from "bullmq";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "./utils/prisma";
|
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
|
||||||
|
|
||||||
type OmniInboundEnvelopeV1 = {
|
|
||||||
version: 1;
|
|
||||||
idempotencyKey: string;
|
|
||||||
provider: string;
|
|
||||||
channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL";
|
|
||||||
direction: "IN" | "OUT";
|
|
||||||
providerEventId: string;
|
|
||||||
providerMessageId: string | null;
|
|
||||||
eventType: string;
|
|
||||||
occurredAt: string;
|
|
||||||
receivedAt: string;
|
|
||||||
payloadRaw: unknown;
|
|
||||||
payloadNormalized: {
|
|
||||||
threadExternalId: string | null;
|
|
||||||
contactExternalId: string | null;
|
|
||||||
text: string | null;
|
|
||||||
businessConnectionId: string | null;
|
|
||||||
updateId?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RECEIVER_FLOW_QUEUE_NAME = (process.env.RECEIVER_FLOW_QUEUE_NAME || "receiver.flow").trim();
|
|
||||||
const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram ";
|
|
||||||
|
|
||||||
function redisConnectionFromEnv(): ConnectionOptions {
|
|
||||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
|
||||||
const parsed = new URL(raw);
|
|
||||||
return {
|
|
||||||
host: parsed.hostname,
|
|
||||||
port: parsed.port ? Number(parsed.port) : 6379,
|
|
||||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
||||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
||||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeText(input: unknown) {
|
|
||||||
const t = String(input ?? "").trim();
|
|
||||||
return t || "[no text]";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOccurredAt(input: string | null | undefined) {
|
|
||||||
const d = new Date(String(input ?? ""));
|
|
||||||
if (Number.isNaN(d.getTime())) return new Date();
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asString(input: unknown) {
|
|
||||||
if (typeof input !== "string") return null;
|
|
||||||
const trimmed = input.trim();
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDirection(input: unknown): "IN" | "OUT" {
|
|
||||||
return input === "OUT" ? "OUT" : "IN";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown) {
|
|
||||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContactProfile = {
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildContactProfile(
|
|
||||||
normalized: OmniInboundEnvelopeV1["payloadNormalized"],
|
|
||||||
externalContactId: string,
|
|
||||||
): ContactProfile {
|
|
||||||
const firstName =
|
|
||||||
asString(normalized.contactFirstName) ??
|
|
||||||
asString(normalized.fromFirstName) ??
|
|
||||||
asString(normalized.chatFirstName);
|
|
||||||
const lastName =
|
|
||||||
asString(normalized.contactLastName) ??
|
|
||||||
asString(normalized.fromLastName) ??
|
|
||||||
asString(normalized.chatLastName);
|
|
||||||
const username =
|
|
||||||
asString(normalized.contactUsername) ??
|
|
||||||
asString(normalized.fromUsername) ??
|
|
||||||
asString(normalized.chatUsername);
|
|
||||||
const title = asString(normalized.contactTitle) ?? asString(normalized.chatTitle);
|
|
||||||
|
|
||||||
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
||||||
const displayName =
|
|
||||||
fullName ||
|
|
||||||
(username ? `@${username.replace(/^@/, "")}` : null) ||
|
|
||||||
title ||
|
|
||||||
`${TELEGRAM_PLACEHOLDER_PREFIX}${externalContactId}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayName,
|
|
||||||
avatarUrl: asString(normalized.contactAvatarUrl),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybeHydrateContact(contactId: string, profile: ContactProfile) {
|
|
||||||
const current = await prisma.contact.findUnique({
|
|
||||||
where: { id: contactId },
|
|
||||||
select: { name: true, avatarUrl: true },
|
|
||||||
});
|
|
||||||
if (!current) return;
|
|
||||||
|
|
||||||
const updates: Prisma.ContactUpdateInput = {};
|
|
||||||
const currentName = asString(current.name);
|
|
||||||
const nextName = asString(profile.displayName);
|
|
||||||
|
|
||||||
if (nextName && (!currentName || currentName.startsWith(TELEGRAM_PLACEHOLDER_PREFIX)) && currentName !== nextName) {
|
|
||||||
updates.name = nextName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAvatar = asString(current.avatarUrl);
|
|
||||||
if (profile.avatarUrl && !currentAvatar) {
|
|
||||||
updates.avatarUrl = profile.avatarUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) return;
|
|
||||||
await prisma.contact.update({
|
|
||||||
where: { id: contactId },
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveTeamId(env: OmniInboundEnvelopeV1) {
|
|
||||||
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
|
|
||||||
const bcId = String(n.businessConnectionId ?? "").trim();
|
|
||||||
if (bcId) {
|
|
||||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
|
||||||
where: { businessConnectionId: bcId },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
select: { teamId: true },
|
|
||||||
});
|
|
||||||
if (linked?.teamId) return linked.teamId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
|
|
||||||
if (externalContactId) {
|
|
||||||
const pseudo = `link:${externalContactId}`;
|
|
||||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
|
||||||
where: { businessConnectionId: pseudo },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
select: { teamId: true },
|
|
||||||
});
|
|
||||||
if (linked?.teamId) return linked.teamId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackTeamId = String(process.env.DEFAULT_TEAM_ID || "").trim();
|
|
||||||
if (fallbackTeamId) return fallbackTeamId;
|
|
||||||
|
|
||||||
const demo = await prisma.team.findFirst({
|
|
||||||
where: { id: "demo-team" },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
return demo?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveContact(input: {
|
|
||||||
teamId: string;
|
|
||||||
externalContactId: string;
|
|
||||||
profile: ContactProfile;
|
|
||||||
}) {
|
|
||||||
const existingIdentity = await prisma.omniContactIdentity.findFirst({
|
|
||||||
where: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalId: input.externalContactId,
|
|
||||||
},
|
|
||||||
select: { contactId: true },
|
|
||||||
});
|
|
||||||
if (existingIdentity?.contactId) {
|
|
||||||
await maybeHydrateContact(existingIdentity.contactId, input.profile);
|
|
||||||
return existingIdentity.contactId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contact = await prisma.contact.create({
|
|
||||||
data: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
name: input.profile.displayName,
|
|
||||||
avatarUrl: input.profile.avatarUrl,
|
|
||||||
company: null,
|
|
||||||
country: null,
|
|
||||||
location: null,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.omniContactIdentity.create({
|
|
||||||
data: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
contactId: contact.id,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalId: input.externalContactId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!isUniqueConstraintError(error)) throw error;
|
|
||||||
|
|
||||||
const concurrentIdentity = await prisma.omniContactIdentity.findFirst({
|
|
||||||
where: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalId: input.externalContactId,
|
|
||||||
},
|
|
||||||
select: { contactId: true },
|
|
||||||
});
|
|
||||||
if (!concurrentIdentity?.contactId) throw error;
|
|
||||||
|
|
||||||
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
|
|
||||||
await maybeHydrateContact(concurrentIdentity.contactId, input.profile);
|
|
||||||
return concurrentIdentity.contactId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return contact.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertThread(input: {
|
|
||||||
teamId: string;
|
|
||||||
contactId: string;
|
|
||||||
externalChatId: string;
|
|
||||||
businessConnectionId: string | null;
|
|
||||||
title: string | null;
|
|
||||||
}) {
|
|
||||||
const existing = await prisma.omniThread.findFirst({
|
|
||||||
where: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalChatId: input.externalChatId,
|
|
||||||
businessConnectionId: input.businessConnectionId,
|
|
||||||
},
|
|
||||||
select: { id: true, title: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const data: Prisma.OmniThreadUpdateInput = {
|
|
||||||
contactId: input.contactId,
|
|
||||||
};
|
|
||||||
if (input.title && !existing.title) {
|
|
||||||
data.title = input.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.omniThread.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await prisma.omniThread.create({
|
|
||||||
data: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
contactId: input.contactId,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalChatId: input.externalChatId,
|
|
||||||
businessConnectionId: input.businessConnectionId,
|
|
||||||
title: input.title,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!isUniqueConstraintError(error)) throw error;
|
|
||||||
|
|
||||||
const concurrentThread = await prisma.omniThread.findFirst({
|
|
||||||
where: {
|
|
||||||
teamId: input.teamId,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
externalChatId: input.externalChatId,
|
|
||||||
businessConnectionId: input.businessConnectionId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!concurrentThread) throw error;
|
|
||||||
|
|
||||||
await prisma.omniThread.update({
|
|
||||||
where: { id: concurrentThread.id },
|
|
||||||
data: { contactId: input.contactId },
|
|
||||||
});
|
|
||||||
return concurrentThread;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ingestInbound(env: OmniInboundEnvelopeV1) {
|
|
||||||
if (env.channel !== "TELEGRAM") return;
|
|
||||||
|
|
||||||
const teamId = await resolveTeamId(env);
|
|
||||||
if (!teamId) {
|
|
||||||
console.warn("[omni_chat] skip inbound: team not resolved", env.providerEventId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
|
|
||||||
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
|
|
||||||
const externalChatId = String(n.threadExternalId ?? n.contactExternalId ?? "").trim();
|
|
||||||
|
|
||||||
if (!externalContactId || !externalChatId) {
|
|
||||||
console.warn("[omni_chat] skip inbound: missing contact/chat ids", env.providerEventId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const businessConnectionId = String(n.businessConnectionId ?? "").trim() || null;
|
|
||||||
const text = normalizeText(n.text);
|
|
||||||
const occurredAt = parseOccurredAt(env.occurredAt);
|
|
||||||
const direction = safeDirection(env.direction);
|
|
||||||
const contactProfile = buildContactProfile(n, externalContactId);
|
|
||||||
|
|
||||||
const contactId = await resolveContact({
|
|
||||||
teamId,
|
|
||||||
externalContactId,
|
|
||||||
profile: contactProfile,
|
|
||||||
});
|
|
||||||
const thread = await upsertThread({
|
|
||||||
teamId,
|
|
||||||
contactId,
|
|
||||||
externalChatId,
|
|
||||||
businessConnectionId,
|
|
||||||
title: asString(n.chatTitle),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (env.providerMessageId) {
|
|
||||||
await prisma.omniMessage.upsert({
|
|
||||||
where: {
|
|
||||||
threadId_providerMessageId: {
|
|
||||||
threadId: thread.id,
|
|
||||||
providerMessageId: env.providerMessageId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
teamId,
|
|
||||||
contactId,
|
|
||||||
threadId: thread.id,
|
|
||||||
direction,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
status: "DELIVERED",
|
|
||||||
text,
|
|
||||||
providerMessageId: env.providerMessageId,
|
|
||||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
|
||||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
|
||||||
occurredAt,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
text,
|
|
||||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
|
||||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
|
||||||
occurredAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await prisma.omniMessage.create({
|
|
||||||
data: {
|
|
||||||
teamId,
|
|
||||||
contactId,
|
|
||||||
threadId: thread.id,
|
|
||||||
direction,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
status: "DELIVERED",
|
|
||||||
text,
|
|
||||||
providerMessageId: null,
|
|
||||||
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
|
|
||||||
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
|
|
||||||
occurredAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.contactMessage.create({
|
|
||||||
data: {
|
|
||||||
contactId,
|
|
||||||
kind: "MESSAGE",
|
|
||||||
direction,
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
content: text,
|
|
||||||
occurredAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let workerInstance: Worker<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
|
|
||||||
|
|
||||||
export function startReceiverWorker() {
|
|
||||||
if (workerInstance) return workerInstance;
|
|
||||||
|
|
||||||
const worker = new Worker<OmniInboundEnvelopeV1, unknown, "ingest">(
|
|
||||||
RECEIVER_FLOW_QUEUE_NAME,
|
|
||||||
async (job) => {
|
|
||||||
await ingestInbound(job.data);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connection: redisConnectionFromEnv(),
|
|
||||||
concurrency: Number(process.env.OMNI_CHAT_WORKER_CONCURRENCY || 4),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
worker.on("failed", (job: Job<OmniInboundEnvelopeV1, unknown, "ingest"> | undefined, err: Error) => {
|
|
||||||
console.error(`[omni_chat] receiver job failed id=${job?.id || "unknown"}: ${err?.message || err}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
workerInstance = worker;
|
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeReceiverWorker() {
|
|
||||||
if (!workerInstance) return;
|
|
||||||
await workerInstance.close();
|
|
||||||
workerInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function receiverQueue() {
|
|
||||||
return new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
|
|
||||||
connection: redisConnectionFromEnv(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# omni_inbound
|
|
||||||
|
|
||||||
Отдельный сервис приема входящих webhook-событий каналов (первый канал: Telegram Business).
|
|
||||||
|
|
||||||
## Задача сервиса
|
|
||||||
|
|
||||||
- принимать webhook;
|
|
||||||
- валидировать секрет;
|
|
||||||
- нормализовать событие в универсальный envelope;
|
|
||||||
- делать durable enqueue в BullMQ (`receiver.flow`);
|
|
||||||
- возвращать `200` только после успешного enqueue.
|
|
||||||
|
|
||||||
Сервис **не** содержит бизнес-логику CRM и не вызывает provider API для исходящих сообщений.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### `GET /health`
|
|
||||||
Проверка живости сервиса.
|
|
||||||
|
|
||||||
### `POST /webhooks/telegram/business`
|
|
||||||
Прием Telegram Business webhook.
|
|
||||||
|
|
||||||
При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок:
|
|
||||||
|
|
||||||
- `x-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>`
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
- `PORT` (default: `8080`)
|
|
||||||
- `REDIS_URL` (default: `redis://localhost:6379`)
|
|
||||||
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
|
|
||||||
- `INBOUND_QUEUE_NAME` (legacy alias, optional)
|
|
||||||
- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production)
|
|
||||||
- `TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL` (optional; URL CRM endpoint для линковки Telegram Business)
|
|
||||||
- `MAX_BODY_SIZE_BYTES` (default: `1048576`)
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm ci
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Надежность
|
|
||||||
|
|
||||||
- Идемпотентность: `jobId` строится из `idempotencyKey` (SHA-256).
|
|
||||||
- Дубликаты webhook безопасны и не приводят к повторной постановке события.
|
|
||||||
- При ошибке enqueue сервис возвращает `503`, чтобы провайдер повторил доставку.
|
|
||||||
908
omni_inbound/package-lock.json
generated
908
omni_inbound/package-lock.json
generated
@@ -1,908 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crm-omni-inbound",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "crm-omni-inbound",
|
|
||||||
"dependencies": {
|
|
||||||
"bullmq": "^5.58.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.13.9",
|
|
||||||
"tsx": "^4.20.5",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ioredis/commands": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "22.19.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
|
||||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bullmq": {
|
|
||||||
"version": "5.69.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.4.tgz",
|
|
||||||
"integrity": "sha512-Lp7ymp875I/rtjMm6oxzQ3PrvDDHkgge0oaAznmZsKtGyglfdrg9zbidPSszTXgWFkS2rCgMcTRNJfM3uUMOjQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cron-parser": "4.9.0",
|
|
||||||
"ioredis": "5.9.2",
|
|
||||||
"msgpackr": "1.11.5",
|
|
||||||
"node-abort-controller": "3.1.1",
|
|
||||||
"semver": "7.7.4",
|
|
||||||
"tslib": "2.8.1",
|
|
||||||
"uuid": "11.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cluster-key-slot": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cron-parser": {
|
|
||||||
"version": "4.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
|
||||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"luxon": "^3.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.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/denque": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
|
||||||
"version": "0.27.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
|
||||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.27.3",
|
|
||||||
"@esbuild/android-arm": "0.27.3",
|
|
||||||
"@esbuild/android-arm64": "0.27.3",
|
|
||||||
"@esbuild/android-x64": "0.27.3",
|
|
||||||
"@esbuild/darwin-arm64": "0.27.3",
|
|
||||||
"@esbuild/darwin-x64": "0.27.3",
|
|
||||||
"@esbuild/freebsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/freebsd-x64": "0.27.3",
|
|
||||||
"@esbuild/linux-arm": "0.27.3",
|
|
||||||
"@esbuild/linux-arm64": "0.27.3",
|
|
||||||
"@esbuild/linux-ia32": "0.27.3",
|
|
||||||
"@esbuild/linux-loong64": "0.27.3",
|
|
||||||
"@esbuild/linux-mips64el": "0.27.3",
|
|
||||||
"@esbuild/linux-ppc64": "0.27.3",
|
|
||||||
"@esbuild/linux-riscv64": "0.27.3",
|
|
||||||
"@esbuild/linux-s390x": "0.27.3",
|
|
||||||
"@esbuild/linux-x64": "0.27.3",
|
|
||||||
"@esbuild/netbsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/netbsd-x64": "0.27.3",
|
|
||||||
"@esbuild/openbsd-arm64": "0.27.3",
|
|
||||||
"@esbuild/openbsd-x64": "0.27.3",
|
|
||||||
"@esbuild/openharmony-arm64": "0.27.3",
|
|
||||||
"@esbuild/sunos-x64": "0.27.3",
|
|
||||||
"@esbuild/win32-arm64": "0.27.3",
|
|
||||||
"@esbuild/win32-ia32": "0.27.3",
|
|
||||||
"@esbuild/win32-x64": "0.27.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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/get-tsconfig": {
|
|
||||||
"version": "4.13.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
|
||||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ioredis": {
|
|
||||||
"version": "5.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
|
|
||||||
"integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@ioredis/commands": "1.5.0",
|
|
||||||
"cluster-key-slot": "^1.1.0",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"denque": "^2.1.0",
|
|
||||||
"lodash.defaults": "^4.2.0",
|
|
||||||
"lodash.isarguments": "^3.1.0",
|
|
||||||
"redis-errors": "^1.2.0",
|
|
||||||
"redis-parser": "^3.0.0",
|
|
||||||
"standard-as-callback": "^2.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.22.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/ioredis"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash.defaults": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isarguments": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/luxon": {
|
|
||||||
"version": "3.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
|
||||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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/msgpackr": {
|
|
||||||
"version": "1.11.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
|
||||||
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"msgpackr-extract": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/msgpackr-extract": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"node-gyp-build-optional-packages": "5.2.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-abort-controller": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/node-gyp-build-optional-packages": {
|
|
||||||
"version": "5.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
|
||||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-gyp-build-optional-packages": "bin.js",
|
|
||||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
|
||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redis-errors": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redis-parser": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"redis-errors": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-pkg-maps": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
|
||||||
"version": "7.7.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
|
||||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/standard-as-callback": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/tsx": {
|
|
||||||
"version": "4.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "~0.27.0",
|
|
||||||
"get-tsconfig": "^4.7.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"tsx": "dist/cli.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": 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/uuid": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { Queue, type ConnectionOptions } from "bullmq";
|
|
||||||
import type { OmniInboundEnvelopeV1 } from "./types";
|
|
||||||
|
|
||||||
export const RECEIVER_FLOW_QUEUE_NAME = (
|
|
||||||
process.env.RECEIVER_FLOW_QUEUE_NAME ||
|
|
||||||
process.env.INBOUND_QUEUE_NAME ||
|
|
||||||
"receiver.flow"
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
let queueInstance: Queue<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
|
|
||||||
|
|
||||||
function redisConnectionFromEnv(): ConnectionOptions {
|
|
||||||
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
|
|
||||||
const parsed = new URL(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
host: parsed.hostname,
|
|
||||||
port: parsed.port ? Number(parsed.port) : 6379,
|
|
||||||
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
|
|
||||||
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
|
|
||||||
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
|
|
||||||
maxRetriesPerRequest: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toJobId(idempotencyKey: string) {
|
|
||||||
const hash = createHash("sha256").update(idempotencyKey).digest("hex");
|
|
||||||
return `inbound-${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inboundQueue() {
|
|
||||||
if (queueInstance) return queueInstance;
|
|
||||||
|
|
||||||
queueInstance = new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
|
|
||||||
connection: redisConnectionFromEnv(),
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: { count: 10000 },
|
|
||||||
removeOnFail: { count: 20000 },
|
|
||||||
attempts: 8,
|
|
||||||
backoff: { type: "exponential", delay: 1000 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return queueInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueInboundEvent(envelope: OmniInboundEnvelopeV1) {
|
|
||||||
const q = inboundQueue();
|
|
||||||
const jobId = toJobId(envelope.idempotencyKey);
|
|
||||||
|
|
||||||
return q.add("ingest", envelope, {
|
|
||||||
jobId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDuplicateJobError(error: unknown) {
|
|
||||||
if (!error || typeof error !== "object") return false;
|
|
||||||
const message = String((error as { message?: string }).message || "").toLowerCase();
|
|
||||||
return message.includes("job") && message.includes("exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeInboundQueue() {
|
|
||||||
if (!queueInstance) return;
|
|
||||||
await queueInstance.close();
|
|
||||||
queueInstance = null;
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
||||||
import { RECEIVER_FLOW_QUEUE_NAME, enqueueInboundEvent, isDuplicateJobError } from "./queue";
|
|
||||||
import { parseTelegramBusinessUpdate } from "./telegram";
|
|
||||||
|
|
||||||
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
|
|
||||||
|
|
||||||
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
|
|
||||||
const payload = JSON.stringify(body);
|
|
||||||
res.statusCode = statusCode;
|
|
||||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
||||||
res.end(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
for await (const chunk of req) {
|
|
||||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
total += buf.length;
|
|
||||||
if (total > MAX_BODY_SIZE_BYTES) {
|
|
||||||
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
|
|
||||||
}
|
|
||||||
chunks.push(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = Buffer.concat(chunks).toString("utf8");
|
|
||||||
if (!raw) return {};
|
|
||||||
return JSON.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateTelegramSecret(req: IncomingMessage): boolean {
|
|
||||||
const expected = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
|
||||||
if (!expected) return true;
|
|
||||||
|
|
||||||
const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim();
|
|
||||||
return incoming !== "" && incoming === expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forwardTelegramConnectWebhook(rawBody: unknown) {
|
|
||||||
const url = (process.env.TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL || "").trim();
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"content-type": "application/json",
|
|
||||||
};
|
|
||||||
const secret = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
|
|
||||||
if (secret) {
|
|
||||||
headers["x-telegram-bot-api-secret-token"] = secret;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(rawBody ?? {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
console.warn(`[omni_inbound] telegram connect forward failed: ${res.status} ${text.slice(0, 300)}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
console.warn(`[omni_inbound] telegram connect forward error: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startServer() {
|
|
||||||
const port = Number(process.env.PORT || 8080);
|
|
||||||
|
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
if (!req.url || !req.method) {
|
|
||||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === "/health" && req.method === "GET") {
|
|
||||||
writeJson(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
service: "omni_inbound",
|
|
||||||
queue: RECEIVER_FLOW_QUEUE_NAME,
|
|
||||||
now: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === "/webhooks/telegram/business" && req.method === "POST") {
|
|
||||||
if (!validateTelegramSecret(req)) {
|
|
||||||
writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: unknown = {};
|
|
||||||
let envelope: ReturnType<typeof parseTelegramBusinessUpdate> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
body = await readJsonBody(req);
|
|
||||||
envelope = parseTelegramBusinessUpdate(body);
|
|
||||||
|
|
||||||
await enqueueInboundEvent(envelope);
|
|
||||||
|
|
||||||
void forwardTelegramConnectWebhook(body);
|
|
||||||
|
|
||||||
writeJson(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
queued: true,
|
|
||||||
duplicate: false,
|
|
||||||
providerEventId: envelope.providerEventId,
|
|
||||||
idempotencyKey: envelope.idempotencyKey,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isDuplicateJobError(error)) {
|
|
||||||
void forwardTelegramConnectWebhook(body);
|
|
||||||
|
|
||||||
writeJson(res, 200, {
|
|
||||||
ok: true,
|
|
||||||
queued: false,
|
|
||||||
duplicate: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const statusCode = message.startsWith("payload_too_large:") ? 413 : 503;
|
|
||||||
writeJson(res, statusCode, {
|
|
||||||
ok: false,
|
|
||||||
error: "receiver_enqueue_failed",
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
|
||||||
console.log(`[omni_inbound] listening on :${port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import type { JsonObject, OmniInboundEnvelopeV1 } from "./types";
|
|
||||||
|
|
||||||
const MAX_TEXT_LENGTH = 4096;
|
|
||||||
|
|
||||||
function asObject(value: unknown): JsonObject {
|
|
||||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickMessage(update: JsonObject): JsonObject {
|
|
||||||
const candidates = [
|
|
||||||
update.message,
|
|
||||||
update.edited_message,
|
|
||||||
update.business_message,
|
|
||||||
update.edited_business_message,
|
|
||||||
update.channel_post,
|
|
||||||
update.edited_channel_post,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const obj = asObject(candidate);
|
|
||||||
if (Object.keys(obj).length > 0) return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickEventType(update: JsonObject): string {
|
|
||||||
if (update.business_message) return "business_message";
|
|
||||||
if (update.edited_business_message) return "edited_business_message";
|
|
||||||
if (update.business_connection) return "business_connection";
|
|
||||||
if (update.deleted_business_messages) return "deleted_business_messages";
|
|
||||||
if (update.message) return "message";
|
|
||||||
if (update.edited_message) return "edited_message";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoFromUnix(value: unknown) {
|
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
||||||
return new Date(value * 1000).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cropText(value: unknown) {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
return value.slice(0, MAX_TEXT_LENGTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeString(value: unknown) {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const normalized = value.trim();
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectDirection(message: JsonObject, chat: JsonObject, from: JsonObject): "IN" | "OUT" {
|
|
||||||
if (typeof message.outgoing === "boolean") return message.outgoing ? "OUT" : "IN";
|
|
||||||
if (typeof message.is_outgoing === "boolean") return message.is_outgoing ? "OUT" : "IN";
|
|
||||||
if (typeof message.out === "boolean") return message.out ? "OUT" : "IN";
|
|
||||||
|
|
||||||
const chatType = normalizeString(chat.type);
|
|
||||||
if (chatType === "private" && from.is_bot === true) return "OUT";
|
|
||||||
|
|
||||||
const chatId = chat.id != null ? String(chat.id) : null;
|
|
||||||
const fromId = from.id != null ? String(from.id) : null;
|
|
||||||
if (chatType === "private" && chatId && fromId && chatId !== fromId) {
|
|
||||||
return "OUT";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "IN";
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireString(value: unknown, fallback: string) {
|
|
||||||
const v = String(value ?? "").trim();
|
|
||||||
return v || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFallbackEventId(raw: unknown) {
|
|
||||||
return createHash("sha256").update(JSON.stringify(raw ?? null)).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1 {
|
|
||||||
const update = asObject(raw);
|
|
||||||
const message = pickMessage(update);
|
|
||||||
const receivedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
const updateId = update.update_id;
|
|
||||||
const messageId = message.message_id;
|
|
||||||
const businessConnection = asObject(update.business_connection);
|
|
||||||
|
|
||||||
const providerEventId =
|
|
||||||
(updateId != null && requireString(updateId, "")) ||
|
|
||||||
(messageId != null && requireString(messageId, "")) ||
|
|
||||||
makeFallbackEventId(raw);
|
|
||||||
|
|
||||||
const providerMessageId = messageId != null ? String(messageId) : null;
|
|
||||||
|
|
||||||
const chat = asObject(message.chat);
|
|
||||||
const from = asObject(message.from);
|
|
||||||
const direction = detectDirection(message, chat, from);
|
|
||||||
const contactSource = direction === "OUT" && Object.keys(chat).length > 0 ? chat : from;
|
|
||||||
const fallbackContactSource = direction === "OUT" ? from : chat;
|
|
||||||
|
|
||||||
const threadExternalId =
|
|
||||||
chat.id != null
|
|
||||||
? String(chat.id)
|
|
||||||
: businessConnection.user_chat_id != null
|
|
||||||
? String(businessConnection.user_chat_id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const contactExternalId =
|
|
||||||
contactSource.id != null
|
|
||||||
? String(contactSource.id)
|
|
||||||
: fallbackContactSource.id != null
|
|
||||||
? String(fallbackContactSource.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const text = cropText(message.text) ?? cropText(message.caption);
|
|
||||||
|
|
||||||
const businessConnectionId =
|
|
||||||
message.business_connection_id != null
|
|
||||||
? String(message.business_connection_id)
|
|
||||||
: businessConnection.id != null
|
|
||||||
? String(businessConnection.id)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const occurredAt =
|
|
||||||
isoFromUnix(message.date) ??
|
|
||||||
isoFromUnix(businessConnection.date) ??
|
|
||||||
receivedAt;
|
|
||||||
|
|
||||||
const eventType = pickEventType(update);
|
|
||||||
|
|
||||||
const idempotencyKey = ["telegram_business", providerEventId, businessConnectionId || "no-bc"].join(":");
|
|
||||||
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
idempotencyKey,
|
|
||||||
provider: "telegram_business",
|
|
||||||
channel: "TELEGRAM",
|
|
||||||
direction,
|
|
||||||
providerEventId,
|
|
||||||
providerMessageId,
|
|
||||||
eventType,
|
|
||||||
occurredAt,
|
|
||||||
receivedAt,
|
|
||||||
payloadRaw: raw,
|
|
||||||
payloadNormalized: {
|
|
||||||
threadExternalId,
|
|
||||||
contactExternalId,
|
|
||||||
text,
|
|
||||||
businessConnectionId,
|
|
||||||
updateId: updateId != null ? String(updateId) : null,
|
|
||||||
chatTitle: typeof chat.title === "string" ? chat.title : null,
|
|
||||||
chatUsername: normalizeString(chat.username),
|
|
||||||
chatFirstName: normalizeString(chat.first_name),
|
|
||||||
chatLastName: normalizeString(chat.last_name),
|
|
||||||
contactUsername: normalizeString(contactSource.username),
|
|
||||||
contactFirstName: normalizeString(contactSource.first_name),
|
|
||||||
contactLastName: normalizeString(contactSource.last_name),
|
|
||||||
contactTitle: normalizeString(contactSource.title),
|
|
||||||
contactAvatarUrl: normalizeString(contactSource.photo_url),
|
|
||||||
fromUsername: typeof from.username === "string" ? from.username : null,
|
|
||||||
fromFirstName: typeof from.first_name === "string" ? from.first_name : null,
|
|
||||||
fromLastName: typeof from.last_name === "string" ? from.last_name : null,
|
|
||||||
fromIsBot: from.is_bot === true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM node:22-bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app/delivery
|
|
||||||
|
|
||||||
RUN apt-get update -y \
|
|
||||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
COPY prisma ./prisma
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
COPY src ./src
|
|
||||||
COPY tsconfig.json ./tsconfig.json
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# omni_outbound
|
|
||||||
|
|
||||||
Изолированный сервис исходящей доставки.
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
|
|
||||||
- потребляет задачи из `sender.flow`;
|
|
||||||
- выполняет отправку в провайдеров;
|
|
||||||
- применяет retry/backoff и финальный fail-status.
|
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
- `REDIS_URL`
|
|
||||||
- `DATABASE_URL`
|
|
||||||
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
|
|
||||||
- `OUTBOUND_DELIVERY_QUEUE_NAME` (legacy alias, optional)
|
|
||||||
|
|
||||||
## Prisma policy
|
|
||||||
|
|
||||||
- Источник схемы: `frontend/prisma/schema.prisma`.
|
|
||||||
- Локальная копия в `omni_outbound/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
|
||||||
- Миграции/`db push` выполняются только в `frontend`.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user