Compare commits
146 Commits
ab5370c831
...
codex/pock
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fc4f66cc | ||
|
|
3d7e963087 | ||
|
|
c1fc8841bd | ||
|
|
9d1e667895 | ||
|
|
3fc9e44e23 | ||
|
|
6d1bface33 | ||
|
|
030ce1e0df | ||
|
|
ae7e453969 | ||
|
|
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 |
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
|
||||||
|
|||||||
14
.gitmodules
vendored
14
.gitmodules
vendored
@@ -1,3 +1,15 @@
|
|||||||
[submodule "instructions"]
|
[submodule "instructions"]
|
||||||
path = instructions
|
path = instructions
|
||||||
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
|
url = https://gitea.dsrptlab.com/dsrptlab/instructions.git
|
||||||
|
[submodule "frontend"]
|
||||||
|
path = frontend
|
||||||
|
url = https://gitea.dsrptlab.com/clientflow/frontend.git
|
||||||
|
branch = main
|
||||||
|
[submodule "backend"]
|
||||||
|
path = backend
|
||||||
|
url = https://gitea.dsrptlab.com/clientflow/backend.git
|
||||||
|
branch = main
|
||||||
|
[submodule "backend_worker"]
|
||||||
|
path = backend_worker
|
||||||
|
url = https://gitea.dsrptlab.com/clientflow/backend_worker.git
|
||||||
|
branch = main
|
||||||
|
|||||||
1
backend
Submodule
1
backend
Submodule
Submodule backend added at e8e2fe6f7f
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`.
|
|
||||||
|
|||||||
80
docs/adr/0002-pocketbase-gvisor-microapps.md
Normal file
80
docs/adr/0002-pocketbase-gvisor-microapps.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR-0002: PocketBase + gVisor Platform For Agent-Built Microapps
|
||||||
|
|
||||||
|
Дата: 2026-03-23
|
||||||
|
Статус: accepted
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Нужен минимальный платформенный слой, в котором агент может собирать микро-приложения для клиентов по жёсткому шаблону:
|
||||||
|
|
||||||
|
- данные и auth живут в PocketBase;
|
||||||
|
- код приложения исполняется изолированно в Deno runtime;
|
||||||
|
- delivery идёт только из шаблонного репозитория и после валидации;
|
||||||
|
- control-plane хранится в основной Prisma-базе проекта.
|
||||||
|
|
||||||
|
Ключевые ограничения:
|
||||||
|
|
||||||
|
- агент не должен деплоить произвольный код вне шаблона;
|
||||||
|
- приложения разных клиентов должны быть логически и операционно изолированы;
|
||||||
|
- база данных и execution host разводятся в разные контуры;
|
||||||
|
- rollout и rollback должны опираться на versioned deploy records.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Принимаем MVP-схему из двух data-plane компонентов и одного control-plane:
|
||||||
|
|
||||||
|
1. `PocketBase`
|
||||||
|
- отдельный сервис данных для микро-приложений;
|
||||||
|
- каждое приложение получает собственный `pocketbaseInstanceId` и URL;
|
||||||
|
- доступ из микро-приложения идёт только через разрешённый service account.
|
||||||
|
|
||||||
|
2. `Deno runner` в `gVisor`
|
||||||
|
- каждое приложение исполняется как контейнер с `runsc`;
|
||||||
|
- runtime получает лимиты по CPU/RAM/PIDs и whitelist по env/network;
|
||||||
|
- runner запускает только Deno entrypoint из шаблона микро-приложения.
|
||||||
|
|
||||||
|
3. `backend` как control-plane
|
||||||
|
- хранит `MicroAppProject`, `MicroAppRuntimeConfig`, `MicroAppDeployment`;
|
||||||
|
- выдаёт конфиг для provisioning и delivery;
|
||||||
|
- знает путь репозитория, ветку, template checksum, preview/prod URL.
|
||||||
|
|
||||||
|
## Поток
|
||||||
|
|
||||||
|
1. Агент создаёт проект только из `microapps/templates/pocketbase-deno-dashboard`.
|
||||||
|
2. Валидатор проверяет структуру репозитория и `app.config.ts`.
|
||||||
|
3. Control-plane создаёт или обновляет запись `MicroAppProject`.
|
||||||
|
4. Delivery pipeline фиксирует новую версию в `MicroAppDeployment`.
|
||||||
|
5. Runner поднимает Deno-контейнер через `gVisor`.
|
||||||
|
6. Публикация идёт через preview domain, а promotion в основной домен выполняется отдельно.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
- source of truth: git-репозиторий приложения;
|
||||||
|
- deploy unit: versioned template-compliant app directory;
|
||||||
|
- preview создаётся автоматически после успешной валидации;
|
||||||
|
- production переключается через отдельный promote шаг;
|
||||||
|
- rollback выполняется redeploy предыдущей записи `MicroAppDeployment`.
|
||||||
|
|
||||||
|
## Домены
|
||||||
|
|
||||||
|
На первом этапе платформа хранит:
|
||||||
|
|
||||||
|
- `primaryDomain`
|
||||||
|
- `previewDomain`
|
||||||
|
|
||||||
|
Привязка доменов и DNS-автоматизация выносятся в отдельный шаг интеграции с Cloudflare и не блокируют запуск control-plane MVP.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
Плюсы:
|
||||||
|
|
||||||
|
- появляется единый control-plane для клиентских микро-приложений;
|
||||||
|
- есть жёсткий шаблон и проверяемый runtime policy;
|
||||||
|
- база данных и execution host разделены;
|
||||||
|
- есть основа для preview/prod flow и rollback.
|
||||||
|
|
||||||
|
Минусы:
|
||||||
|
|
||||||
|
- runtime по-прежнему требует отдельного delivery automation поверх git;
|
||||||
|
- provisioning PocketBase instance пока остаётся внешней операцией;
|
||||||
|
- домены и TLS-автоматизация ещё не включены в MVP-каркас.
|
||||||
@@ -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 3518c80b92
@@ -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"]
|
|
||||||
7030
frontend/app.vue
7030
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
instructions.md
Symbolic link
1
instructions.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
instructions/instructions.md
|
||||||
22
microapps/runtime/Dockerfile
Normal file
22
microapps/runtime/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG DENO_VERSION=2.6.4
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --yes --no-install-recommends ca-certificates curl tini unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://dl.deno.land/release/v${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||||
|
&& unzip /tmp/deno.zip -d /usr/local/bin \
|
||||||
|
&& chmod +x /usr/local/bin/deno \
|
||||||
|
&& rm /tmp/deno.zip
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY microapps/runtime/entrypoint.sh /usr/local/bin/microapp-entrypoint
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/microapp-entrypoint
|
||||||
|
|
||||||
|
ENV DENO_DIR=/deno-dir
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/microapp-entrypoint"]
|
||||||
15
microapps/runtime/entrypoint.sh
Normal file
15
microapps/runtime/entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/workspace/app}"
|
||||||
|
ENTRYPOINT_FILE="${ENTRYPOINT_FILE:-$APP_DIR/main.ts}"
|
||||||
|
ALLOW_READ="${ALLOW_READ:-$APP_DIR}"
|
||||||
|
ALLOW_ENV="${ALLOW_ENV:-PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG}"
|
||||||
|
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
|
||||||
|
|
||||||
|
exec deno run \
|
||||||
|
--no-prompt \
|
||||||
|
--allow-read="$ALLOW_READ" \
|
||||||
|
--allow-env="$ALLOW_ENV" \
|
||||||
|
--allow-net="$ALLOW_NET" \
|
||||||
|
"$ENTRYPOINT_FILE"
|
||||||
17
microapps/runtime/run-with-gvisor.sh
Normal file
17
microapps/runtime/run-with-gvisor.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
IMAGE_NAME="${IMAGE_NAME:-clientflow/microapp-runtime}"
|
||||||
|
APP_DIR="${APP_DIR:-$(pwd)/microapps/templates/pocketbase-deno-dashboard}"
|
||||||
|
ALLOW_NET="${ALLOW_NET:-127.0.0.1,localhost}"
|
||||||
|
|
||||||
|
docker run \
|
||||||
|
--rm \
|
||||||
|
--runtime=runsc \
|
||||||
|
--memory=256m \
|
||||||
|
--cpus=0.5 \
|
||||||
|
--pids-limit=128 \
|
||||||
|
-e APP_DIR=/workspace/app \
|
||||||
|
-e ALLOW_NET="$ALLOW_NET" \
|
||||||
|
-v "$APP_DIR:/workspace/app:ro" \
|
||||||
|
"$IMAGE_NAME"
|
||||||
28
microapps/templates/pocketbase-deno-dashboard/app.config.ts
Normal file
28
microapps/templates/pocketbase-deno-dashboard/app.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default {
|
||||||
|
manifestVersion: 1,
|
||||||
|
name: "PocketBase Deno Dashboard",
|
||||||
|
slug: "pocketbase-deno-dashboard",
|
||||||
|
runtime: "deno-gvisor",
|
||||||
|
entrypoint: "./main.ts",
|
||||||
|
pocketbase: {
|
||||||
|
collections: ["dashboard_cards"],
|
||||||
|
collectionPrefix: "dashboard",
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
allowEnv: [
|
||||||
|
"PORT",
|
||||||
|
"POCKETBASE_URL",
|
||||||
|
"POCKETBASE_SERVICE_EMAIL",
|
||||||
|
"POCKETBASE_SERVICE_PASSWORD",
|
||||||
|
"APP_OWNER_TEAM_ID",
|
||||||
|
"APP_SLUG",
|
||||||
|
],
|
||||||
|
allowNet: ["pocketbase.internal"],
|
||||||
|
allowRead: ["./"],
|
||||||
|
allowWrite: [],
|
||||||
|
},
|
||||||
|
libraries: [
|
||||||
|
"internal/pocketbase-client",
|
||||||
|
"internal/dashboard-ui",
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
17
microapps/templates/pocketbase-deno-dashboard/deno.json
Normal file
17
microapps/templates/pocketbase-deno-dashboard/deno.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch --allow-read=. --allow-env=PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG --allow-net=127.0.0.1,localhost,pocketbase.internal main.ts",
|
||||||
|
"check": "deno check main.ts pocketbase/client.ts app.config.ts"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"lineWidth": 100,
|
||||||
|
"singleQuote": false
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"rules": {
|
||||||
|
"tags": [
|
||||||
|
"recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
microapps/templates/pocketbase-deno-dashboard/main.ts
Normal file
162
microapps/templates/pocketbase-deno-dashboard/main.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import appConfig from "./app.config.ts";
|
||||||
|
import { createPocketBaseClient } from "./pocketbase/client.ts";
|
||||||
|
|
||||||
|
const port = Number(Deno.env.get("PORT") || "8080");
|
||||||
|
const pocketbaseUrl = mustGetEnv("POCKETBASE_URL");
|
||||||
|
const pocketbaseEmail = mustGetEnv("POCKETBASE_SERVICE_EMAIL");
|
||||||
|
const pocketbasePassword = mustGetEnv("POCKETBASE_SERVICE_PASSWORD");
|
||||||
|
const ownerTeamId = mustGetEnv("APP_OWNER_TEAM_ID");
|
||||||
|
const appSlug = Deno.env.get("APP_SLUG") || appConfig.slug;
|
||||||
|
|
||||||
|
const pocketbase = createPocketBaseClient({
|
||||||
|
baseUrl: pocketbaseUrl,
|
||||||
|
email: pocketbaseEmail,
|
||||||
|
password: pocketbasePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DashboardCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
metric: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderHtml(cards: DashboardCard[]) {
|
||||||
|
const cardsMarkup = cards.length === 0
|
||||||
|
? `<p class="empty">No dashboard cards yet.</p>`
|
||||||
|
: cards.map((card) => `
|
||||||
|
<article class="card">
|
||||||
|
<p class="label">${escapeHtml(card.title)}</p>
|
||||||
|
<strong>${escapeHtml(card.metric)}</strong>
|
||||||
|
<span>${escapeHtml(card.description || "No description")}</span>
|
||||||
|
</article>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${escapeHtml(appConfig.name)}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4efe5;
|
||||||
|
--panel: #fffaf2;
|
||||||
|
--ink: #17231f;
|
||||||
|
--muted: #52615a;
|
||||||
|
--accent: #0d8a62;
|
||||||
|
--border: #d7cbb8;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(13, 138, 98, 0.2), transparent 32%),
|
||||||
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 80px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 250, 242, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(32px, 5vw, 56px);
|
||||||
|
line-height: 0.96;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid rgba(23, 35, 31, 0.08);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 10px 24px rgba(23, 35, 31, 0.08);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 34px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
span, .empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">PocketBase + Deno</p>
|
||||||
|
<h1>${escapeHtml(appConfig.name)}</h1>
|
||||||
|
<p class="meta">App slug: ${escapeHtml(appSlug)} • Team: ${escapeHtml(ownerTeamId)}</p>
|
||||||
|
</section>
|
||||||
|
<section class="grid">${cardsMarkup}</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mustGetEnv(name: string) {
|
||||||
|
const value = Deno.env.get(name);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required env: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest() {
|
||||||
|
const cards = await pocketbase.listRecords<DashboardCard>({
|
||||||
|
collectionName: `${appConfig.pocketbase.collectionPrefix}_cards`,
|
||||||
|
filter: `teamId = "${ownerTeamId}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(renderHtml(cards), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve({ port }, () => handleRequest());
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
type PocketBaseClientOptions = {
|
||||||
|
baseUrl: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PocketBaseListOptions = {
|
||||||
|
collectionName: string;
|
||||||
|
filter?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthResponse = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecordListResponse<TRecord> = {
|
||||||
|
items: TRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPocketBaseClient(options: PocketBaseClientOptions) {
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
if (cachedToken) return cachedToken;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${options.baseUrl}/api/collections/_superusers/auth-with-password`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
identity: options.email,
|
||||||
|
password: options.password,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`PocketBase auth failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as AuthResponse;
|
||||||
|
cachedToken = payload.token;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async listRecords<TRecord>(input: PocketBaseListOptions) {
|
||||||
|
const token = await authenticate();
|
||||||
|
const url = new URL(
|
||||||
|
`${options.baseUrl}/api/collections/${input.collectionName}/records`,
|
||||||
|
);
|
||||||
|
if (input.filter) {
|
||||||
|
url.searchParams.set("filter", input.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`PocketBase list failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json() as RecordListResponse<TRecord>;
|
||||||
|
return payload.items;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
69
microapps/validator/validate_microapp_repo.ts
Normal file
69
microapps/validator/validate_microapp_repo.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const projectDir = Deno.args[0];
|
||||||
|
|
||||||
|
if (!projectDir) {
|
||||||
|
throw new Error("Usage: deno run --allow-read microapps/validator/validate_microapp_repo.ts <project-dir>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFiles = new Set([
|
||||||
|
"app.config.ts",
|
||||||
|
"deno.json",
|
||||||
|
"main.ts",
|
||||||
|
"pocketbase/client.ts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedTopLevelEntries = new Set([
|
||||||
|
"app.config.ts",
|
||||||
|
"deno.json",
|
||||||
|
"main.ts",
|
||||||
|
"pocketbase",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootEntries = new Set<string>();
|
||||||
|
|
||||||
|
for await (const entry of Deno.readDir(projectDir)) {
|
||||||
|
rootEntries.add(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of requiredFiles) {
|
||||||
|
const path = `${projectDir}/${file}`;
|
||||||
|
await Deno.stat(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of rootEntries) {
|
||||||
|
if (!allowedTopLevelEntries.has(entry)) {
|
||||||
|
throw new Error(`Unexpected top-level entry: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configModule = await import(`file://${Deno.realPathSync(`${projectDir}/app.config.ts`)}`);
|
||||||
|
const config = configModule.default as {
|
||||||
|
manifestVersion?: number;
|
||||||
|
runtime?: string;
|
||||||
|
libraries?: string[];
|
||||||
|
permissions?: {
|
||||||
|
allowEnv?: string[];
|
||||||
|
allowNet?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.manifestVersion !== 1) {
|
||||||
|
throw new Error("app.config.ts must export manifestVersion = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.runtime !== "deno-gvisor") {
|
||||||
|
throw new Error("app.config.ts must declare runtime = 'deno-gvisor'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(config.libraries) || config.libraries.length === 0) {
|
||||||
|
throw new Error("app.config.ts must declare at least one approved library");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(config.permissions?.allowEnv) || config.permissions.allowEnv.length === 0) {
|
||||||
|
throw new Error("app.config.ts must declare allowEnv permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(config.permissions?.allowNet) || config.permissions.allowNet.length === 0) {
|
||||||
|
throw new Error("app.config.ts must declare allowNet permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Microapp repo at ${projectDir} matches the enforced template contract.`);
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user