diff --git a/deploy-map.toml b/deploy-map.toml index 449f19a..a112981 100644 --- a/deploy-map.toml +++ b/deploy-map.toml @@ -2,5 +2,7 @@ version = 1 [services] frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } -delivery_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } +omni_outbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } +omni_inbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } +omni_chat = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" } langfuse = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "langfuse/docker-compose.yml" } diff --git a/docs/adr/0001-chat-platform-service-boundaries.md b/docs/adr/0001-chat-platform-service-boundaries.md new file mode 100644 index 0000000..b906e13 --- /dev/null +++ b/docs/adr/0001-chat-platform-service-boundaries.md @@ -0,0 +1,104 @@ +# ADR-0001: Разделение Chat Platform на 3 сервиса + +Дата: 2026-02-21 +Статус: accepted + +## Контекст + +Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`. +Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга. + +Критичные требования: + +- входящие webhook-события не теряются при рестартах; +- delivery управляет retry/rate-limit централизованно; +- omni_chat остается единственным местом доменной логики и хранения состояния диалогов; +- сервисы можно обновлять независимо. + +## Решение + +Принимаем разделение на 3 сервиса: + +1. `omni_inbound` +- Принимает вебхуки провайдеров. +- Валидирует подпись/секрет. +- Нормализует событие в универсальный envelope. +- Пишет событие в durable queue (`receiver.flow`) с идемпотентным `jobId`. +- Возвращает `200` только после успешной durable enqueue. +- Не содержит бизнес-логики CRM. + +2. `omni_chat` +- Потребляет входящие события из `receiver.flow`. +- Разрешает идентичности и треды. +- Создает/обновляет `OmniMessage`, `OmniThread`, статусы и доменные эффекты. +- Формирует исходящие команды и кладет их в `sender.flow`. + +3. `omni_outbound` +- Потребляет `sender.flow`. +- Выполняет отправку в провайдеров (Telegram Business и др.). +- Управляет retry/backoff/failover, DLQ и статусами доставки. +- Не содержит UI и доменной логики чатов. + +## Почему webhook и delivery разделены + +- Входящий контур должен отвечать быстро и предсказуемо. +- Исходящий контур живет с долгими retry и ограничениями провайдера. +- Сбой внешнего API не должен блокировать прием входящих сообщений. + +## Границы ответственности + +`omni_inbound`: + +- можно: auth, валидация, нормализация, дедуп, enqueue; +- нельзя: запись доменных сущностей CRM, принятие продуктовых решений. + +`omni_chat`: + +- можно: вся доменная модель чатов, orchestration, бизнес-правила; +- нельзя: прямые вызовы провайдеров из sync API-контекста. + +`omni_outbound`: + +- можно: провайдерные адаптеры, retry, rate limits; +- нельзя: резолвинг бизнес-правил и маршрутизации диалога. + +## Универсальный протокол событий + +Внутренний контракт входящих событий: `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`. + +## Последствия + +Плюсы: + +- независимые релизы и масштабирование по ролям; +- меньше blast radius при инцидентах; +- проще подключать новые каналы поверх общего контракта. + +Минусы: + +- больше инфраструктурных компонентов (очереди, мониторинг, трассировка); +- требуется дисциплина по контрактам между сервисами. + +## План внедрения + +1. Вводим `omni_inbound` как отдельный сервис для Telegram Business. +2. Потребление `receiver.flow` реализуем в `omni_chat`. +3. Текущее исходящее API оставляем за `omni_outbound`. +4. После стабилизации выносим оставшиеся omni endpoint'ы из `frontend` в `omni_chat`/`omni_inbound`. diff --git a/docs/contracts/omni-inbound-envelope.v1.json b/docs/contracts/omni-inbound-envelope.v1.json new file mode 100644 index 0000000..64275e7 --- /dev/null +++ b/docs/contracts/omni-inbound-envelope.v1.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://crm.local/contracts/omni-inbound-envelope.v1.json", + "title": "OmniInboundEnvelopeV1", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "idempotencyKey", + "provider", + "channel", + "direction", + "providerEventId", + "providerMessageId", + "eventType", + "occurredAt", + "receivedAt", + "payloadRaw", + "payloadNormalized" + ], + "properties": { + "version": { + "const": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "channel": { + "type": "string", + "enum": ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "PHONE", "EMAIL", "INTERNAL"] + }, + "direction": { + "const": "IN" + }, + "providerEventId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "providerMessageId": { + "type": ["string", "null"], + "maxLength": 256 + }, + "eventType": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "receivedAt": { + "type": "string", + "format": "date-time" + }, + "payloadRaw": { + "type": ["object", "array", "string", "number", "boolean", "null"] + }, + "payloadNormalized": { + "type": "object", + "additionalProperties": true, + "required": ["threadExternalId", "contactExternalId", "text", "businessConnectionId"], + "properties": { + "threadExternalId": { + "type": ["string", "null"], + "maxLength": 256 + }, + "contactExternalId": { + "type": ["string", "null"], + "maxLength": 256 + }, + "text": { + "type": ["string", "null"], + "maxLength": 4096 + }, + "businessConnectionId": { + "type": ["string", "null"], + "maxLength": 256 + } + } + } + } +} diff --git a/frontend/nixpacks.toml b/frontend/nixpacks.toml deleted file mode 100644 index dcb1fcc..0000000 --- a/frontend/nixpacks.toml +++ /dev/null @@ -1,8 +0,0 @@ -[phases.install] -cmds = ["npm ci --legacy-peer-deps"] - -[phases.build] -cmds = ["npm run db:generate", "npm run build"] - -[start] -cmd = "npm run preview -- --host 0.0.0.0 --port ${PORT:-3000}" diff --git a/frontend/server/api/omni/delivery/enqueue.post.ts b/frontend/server/api/omni/delivery/enqueue.post.ts index 707388e..892e1f9 100644 --- a/frontend/server/api/omni/delivery/enqueue.post.ts +++ b/frontend/server/api/omni/delivery/enqueue.post.ts @@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => { return { ok: true, - queue: "omni-outbound", + queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow", jobId: job.id, omniMessageId, }; diff --git a/frontend/server/api/omni/telegram/send.post.ts b/frontend/server/api/omni/telegram/send.post.ts index 141d0a5..dcb32e1 100644 --- a/frontend/server/api/omni/telegram/send.post.ts +++ b/frontend/server/api/omni/telegram/send.post.ts @@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => { return { ok: true, - queue: "omni-outbound", + queue: process.env.SENDER_FLOW_QUEUE_NAME || process.env.OUTBOUND_DELIVERY_QUEUE_NAME || "sender.flow", jobId: job.id, omniMessageId, }; diff --git a/frontend/server/queues/outboundDelivery.ts b/frontend/server/queues/outboundDelivery.ts index 3bf9b1a..57cb2a3 100644 --- a/frontend/server/queues/outboundDelivery.ts +++ b/frontend/server/queues/outboundDelivery.ts @@ -2,7 +2,11 @@ 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 = "omni-outbound"; +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; diff --git a/frontend/server/queues/worker.ts b/frontend/server/queues/worker.ts index f934624..d6430d2 100644 --- a/frontend/server/queues/worker.ts +++ b/frontend/server/queues/worker.ts @@ -1,12 +1,12 @@ -import { startOutboundDeliveryWorker } from "./outboundDelivery"; +import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./outboundDelivery"; import { prisma } from "../utils/prisma"; import { getRedis } from "../utils/redis"; const worker = startOutboundDeliveryWorker(); -console.log("[delivery-worker] started queue omni:outbound"); +console.log(`[omni_outbound(legacy-in-frontend)] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`); async function shutdown(signal: string) { - console.log(`[delivery-worker] shutting down by ${signal}`); + console.log(`[omni_outbound(legacy-in-frontend)] shutting down by ${signal}`); try { await worker.close(); } catch { @@ -32,4 +32,3 @@ process.on("SIGINT", () => { process.on("SIGTERM", () => { void shutdown("SIGTERM"); }); - diff --git a/instructions b/instructions index 19bbaf3..4ddd069 160000 --- a/instructions +++ b/instructions @@ -1 +1 @@ -Subproject commit 19bbaf3e08a05156135f75e2cd449af3ebb5d66c +Subproject commit 4ddd069d884622a146c12b8437ec484e10354f56 diff --git a/nixpacks.toml b/nixpacks.toml deleted file mode 100644 index 5986737..0000000 --- a/nixpacks.toml +++ /dev/null @@ -1,8 +0,0 @@ -[phases.install] -cmds = ["cd frontend && npm ci --legacy-peer-deps"] - -[phases.build] -cmds = ["cd frontend && npm run db:generate", "cd frontend && npm run build"] - -[start] -cmd = "cd frontend && npm run preview -- --host 0.0.0.0 --port ${PORT:-3000}" diff --git a/omni_chat/Dockerfile b/omni_chat/Dockerfile new file mode 100644 index 0000000..57cea74 --- /dev/null +++ b/omni_chat/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +CMD ["npm", "run", "start"] diff --git a/omni_chat/README.md b/omni_chat/README.md new file mode 100644 index 0000000..b04021e --- /dev/null +++ b/omni_chat/README.md @@ -0,0 +1,21 @@ +# 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`) diff --git a/omni_chat/package-lock.json b/omni_chat/package-lock.json new file mode 100644 index 0000000..3d58907 --- /dev/null +++ b/omni_chat/package-lock.json @@ -0,0 +1,588 @@ +{ + "name": "crm-omni-chat", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crm-omni-chat", + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/omni_chat/package.json b/omni_chat/package.json new file mode 100644 index 0000000..43afbc6 --- /dev/null +++ b/omni_chat/package.json @@ -0,0 +1,14 @@ +{ + "name": "crm-omni-chat", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/omni_chat/src/index.ts b/omni_chat/src/index.ts new file mode 100644 index 0000000..fadce9e --- /dev/null +++ b/omni_chat/src/index.ts @@ -0,0 +1,36 @@ +import { createServer } from "node:http"; + +const port = Number(process.env.PORT || 8090); +const service = "omni_chat"; + +const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + const payload = JSON.stringify({ + ok: true, + service, + receiverFlow: process.env.RECEIVER_FLOW_QUEUE_NAME || "receiver.flow", + senderFlow: process.env.SENDER_FLOW_QUEUE_NAME || "sender.flow", + now: new Date().toISOString(), + }); + res.statusCode = 200; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(payload); + return; + } + + res.statusCode = 404; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify({ ok: false, error: "not_found" })); +}); + +server.listen(port, "0.0.0.0", () => { + console.log(`[omni_chat] listening on :${port}`); +}); + +function shutdown(signal: string) { + console.log(`[omni_chat] shutting down by ${signal}`); + server.close(() => process.exit(0)); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/delivery/tsconfig.json b/omni_chat/tsconfig.json similarity index 100% rename from delivery/tsconfig.json rename to omni_chat/tsconfig.json diff --git a/omni_inbound/Dockerfile b/omni_inbound/Dockerfile new file mode 100644 index 0000000..57cea74 --- /dev/null +++ b/omni_inbound/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +CMD ["npm", "run", "start"] diff --git a/omni_inbound/README.md b/omni_inbound/README.md new file mode 100644 index 0000000..4240043 --- /dev/null +++ b/omni_inbound/README.md @@ -0,0 +1,47 @@ +# omni_inbound + +Отдельный сервис приема входящих webhook-событий каналов (первый канал: Telegram Business). + +## Задача сервиса + +- принимать webhook; +- валидировать секрет; +- нормализовать событие в универсальный envelope; +- делать durable enqueue в BullMQ (`receiver.flow`); +- возвращать `200` только после успешного enqueue. + +Сервис **не** содержит бизнес-логику CRM и не вызывает provider API для исходящих сообщений. + +## API + +### `GET /health` +Проверка живости сервиса. + +### `POST /webhooks/telegram/business` +Прием Telegram Business webhook. + +При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок: + +- `x-telegram-bot-api-secret-token: ` + +## Переменные окружения + +- `PORT` (default: `8080`) +- `REDIS_URL` (default: `redis://localhost:6379`) +- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`) +- `INBOUND_QUEUE_NAME` (legacy alias, optional) +- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production) +- `MAX_BODY_SIZE_BYTES` (default: `1048576`) + +## Запуск + +```bash +npm ci +npm run start +``` + +## Надежность + +- Идемпотентность: `jobId` строится из `idempotencyKey` (SHA-256). +- Дубликаты webhook безопасны и не приводят к повторной постановке события. +- При ошибке enqueue сервис возвращает `503`, чтобы провайдер повторил доставку. diff --git a/omni_inbound/package-lock.json b/omni_inbound/package-lock.json new file mode 100644 index 0000000..db56168 --- /dev/null +++ b/omni_inbound/package-lock.json @@ -0,0 +1,908 @@ +{ + "name": "crm-omni-inbound", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crm-omni-inbound", + "dependencies": { + "bullmq": "^5.58.2" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/bullmq": { + "version": "5.69.4", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.4.tgz", + "integrity": "sha512-Lp7ymp875I/rtjMm6oxzQ3PrvDDHkgge0oaAznmZsKtGyglfdrg9zbidPSszTXgWFkS2rCgMcTRNJfM3uUMOjQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.2", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + } + } +} diff --git a/omni_inbound/package.json b/omni_inbound/package.json new file mode 100644 index 0000000..f2a4bab --- /dev/null +++ b/omni_inbound/package.json @@ -0,0 +1,17 @@ +{ + "name": "crm-omni-inbound", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "bullmq": "^5.58.2" + }, + "devDependencies": { + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/omni_inbound/src/index.ts b/omni_inbound/src/index.ts new file mode 100644 index 0000000..1242091 --- /dev/null +++ b/omni_inbound/src/index.ts @@ -0,0 +1,38 @@ +import { closeInboundQueue } from "./queue"; +import { startServer } from "./server"; + +const server = startServer(); + +async function shutdown(signal: string) { + console.log(`[omni_inbound] shutting down by ${signal}`); + + try { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } catch { + // ignore shutdown errors + } + + try { + await closeInboundQueue(); + } catch { + // ignore shutdown errors + } + + process.exit(0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); + +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); diff --git a/omni_inbound/src/queue.ts b/omni_inbound/src/queue.ts new file mode 100644 index 0000000..418234e --- /dev/null +++ b/omni_inbound/src/queue.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto"; +import { Queue, type ConnectionOptions } from "bullmq"; +import type { OmniInboundEnvelopeV1 } from "./types"; + +export const RECEIVER_FLOW_QUEUE_NAME = ( + process.env.RECEIVER_FLOW_QUEUE_NAME || + process.env.INBOUND_QUEUE_NAME || + "receiver.flow" +).trim(); + +let queueInstance: Queue | null = null; + +function redisConnectionFromEnv(): ConnectionOptions { + const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim(); + const parsed = new URL(raw); + + return { + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : 6379, + username: parsed.username ? decodeURIComponent(parsed.username) : undefined, + password: parsed.password ? decodeURIComponent(parsed.password) : undefined, + db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined, + maxRetriesPerRequest: null, + }; +} + +function toJobId(idempotencyKey: string) { + const hash = createHash("sha256").update(idempotencyKey).digest("hex"); + return `inbound:${hash}`; +} + +export function inboundQueue() { + if (queueInstance) return queueInstance; + + queueInstance = new Queue(RECEIVER_FLOW_QUEUE_NAME, { + connection: redisConnectionFromEnv(), + defaultJobOptions: { + removeOnComplete: { count: 10000 }, + removeOnFail: { count: 20000 }, + attempts: 8, + backoff: { type: "exponential", delay: 1000 }, + }, + }); + + return queueInstance; +} + +export async function enqueueInboundEvent(envelope: OmniInboundEnvelopeV1) { + const q = inboundQueue(); + const jobId = toJobId(envelope.idempotencyKey); + + return q.add("ingest", envelope, { + jobId, + }); +} + +export function isDuplicateJobError(error: unknown) { + if (!error || typeof error !== "object") return false; + const message = String((error as { message?: string }).message || "").toLowerCase(); + return message.includes("job") && message.includes("exists"); +} + +export async function closeInboundQueue() { + if (!queueInstance) return; + await queueInstance.close(); + queueInstance = null; +} diff --git a/omni_inbound/src/server.ts b/omni_inbound/src/server.ts new file mode 100644 index 0000000..8f0005f --- /dev/null +++ b/omni_inbound/src/server.ts @@ -0,0 +1,108 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { RECEIVER_FLOW_QUEUE_NAME, enqueueInboundEvent, isDuplicateJobError } from "./queue"; +import { parseTelegramBusinessUpdate } from "./telegram"; + +const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024); + +function writeJson(res: ServerResponse, statusCode: number, body: unknown) { + const payload = JSON.stringify(body); + res.statusCode = statusCode; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(payload); +} + +async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buf.length; + if (total > MAX_BODY_SIZE_BYTES) { + throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`); + } + chunks.push(buf); + } + + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) return {}; + return JSON.parse(raw); +} + +function validateTelegramSecret(req: IncomingMessage): boolean { + const expected = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim(); + if (!expected) return true; + + const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim(); + return incoming !== "" && incoming === expected; +} + +export function startServer() { + const port = Number(process.env.PORT || 8080); + + const server = createServer(async (req, res) => { + if (!req.url || !req.method) { + writeJson(res, 404, { ok: false, error: "not_found" }); + return; + } + + if (req.url === "/health" && req.method === "GET") { + writeJson(res, 200, { + ok: true, + service: "omni_inbound", + queue: RECEIVER_FLOW_QUEUE_NAME, + now: new Date().toISOString(), + }); + return; + } + + if (req.url === "/webhooks/telegram/business" && req.method === "POST") { + if (!validateTelegramSecret(req)) { + writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" }); + return; + } + + try { + const body = await readJsonBody(req); + const envelope = parseTelegramBusinessUpdate(body); + + await enqueueInboundEvent(envelope); + + writeJson(res, 200, { + ok: true, + queued: true, + duplicate: false, + providerEventId: envelope.providerEventId, + idempotencyKey: envelope.idempotencyKey, + }); + } catch (error) { + if (isDuplicateJobError(error)) { + writeJson(res, 200, { + ok: true, + queued: false, + duplicate: true, + }); + return; + } + + const message = error instanceof Error ? error.message : String(error); + const statusCode = message.startsWith("payload_too_large:") ? 413 : 503; + writeJson(res, statusCode, { + ok: false, + error: "receiver_enqueue_failed", + message, + }); + } + + return; + } + + writeJson(res, 404, { ok: false, error: "not_found" }); + }); + + server.listen(port, "0.0.0.0", () => { + console.log(`[omni_inbound] listening on :${port}`); + }); + + return server; +} diff --git a/omni_inbound/src/telegram.ts b/omni_inbound/src/telegram.ts new file mode 100644 index 0000000..be8b8f3 --- /dev/null +++ b/omni_inbound/src/telegram.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import type { JsonObject, OmniInboundEnvelopeV1 } from "./types"; + +const MAX_TEXT_LENGTH = 4096; + +function asObject(value: unknown): JsonObject { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {}; +} + +function pickMessage(update: JsonObject): JsonObject { + const candidates = [ + update.message, + update.edited_message, + update.business_message, + update.edited_business_message, + update.channel_post, + update.edited_channel_post, + ]; + + for (const candidate of candidates) { + const obj = asObject(candidate); + if (Object.keys(obj).length > 0) return obj; + } + + return {}; +} + +function pickEventType(update: JsonObject): string { + if (update.business_message) return "business_message"; + if (update.edited_business_message) return "edited_business_message"; + if (update.business_connection) return "business_connection"; + if (update.deleted_business_messages) return "deleted_business_messages"; + if (update.message) return "message"; + if (update.edited_message) return "edited_message"; + return "unknown"; +} + +function isoFromUnix(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + return new Date(value * 1000).toISOString(); +} + +function cropText(value: unknown) { + if (typeof value !== "string") return null; + return value.slice(0, MAX_TEXT_LENGTH); +} + +function requireString(value: unknown, fallback: string) { + const v = String(value ?? "").trim(); + return v || fallback; +} + +function makeFallbackEventId(raw: unknown) { + return createHash("sha256").update(JSON.stringify(raw ?? null)).digest("hex"); +} + +export function parseTelegramBusinessUpdate(raw: unknown): OmniInboundEnvelopeV1 { + const update = asObject(raw); + const message = pickMessage(update); + const receivedAt = new Date().toISOString(); + + const updateId = update.update_id; + const messageId = message.message_id; + const businessConnection = asObject(update.business_connection); + + const providerEventId = + (updateId != null && requireString(updateId, "")) || + (messageId != null && requireString(messageId, "")) || + makeFallbackEventId(raw); + + const providerMessageId = messageId != null ? String(messageId) : null; + + const chat = asObject(message.chat); + const from = asObject(message.from); + + const threadExternalId = + chat.id != null + ? String(chat.id) + : businessConnection.user_chat_id != null + ? String(businessConnection.user_chat_id) + : null; + + const contactExternalId = from.id != null ? String(from.id) : null; + + const text = cropText(message.text) ?? cropText(message.caption); + + const businessConnectionId = + message.business_connection_id != null + ? String(message.business_connection_id) + : businessConnection.id != null + ? String(businessConnection.id) + : null; + + const occurredAt = + isoFromUnix(message.date) ?? + isoFromUnix(businessConnection.date) ?? + receivedAt; + + const eventType = pickEventType(update); + + const idempotencyKey = ["telegram_business", providerEventId, businessConnectionId || "no-bc"].join(":"); + + return { + version: 1, + idempotencyKey, + provider: "telegram_business", + channel: "TELEGRAM", + direction: "IN", + providerEventId, + providerMessageId, + eventType, + occurredAt, + receivedAt, + payloadRaw: raw, + payloadNormalized: { + threadExternalId, + contactExternalId, + text, + businessConnectionId, + updateId: updateId != null ? String(updateId) : null, + chatTitle: typeof chat.title === "string" ? chat.title : null, + fromUsername: typeof from.username === "string" ? from.username : null, + fromFirstName: typeof from.first_name === "string" ? from.first_name : null, + fromLastName: typeof from.last_name === "string" ? from.last_name : null, + }, + }; +} diff --git a/omni_inbound/src/types.ts b/omni_inbound/src/types.ts new file mode 100644 index 0000000..b343d3f --- /dev/null +++ b/omni_inbound/src/types.ts @@ -0,0 +1,24 @@ +export type OmniInboundChannel = "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL"; + +export type OmniInboundEnvelopeV1 = { + version: 1; + idempotencyKey: string; + provider: string; + channel: OmniInboundChannel; + direction: "IN"; + providerEventId: string; + providerMessageId: string | null; + eventType: string; + occurredAt: string; + receivedAt: string; + payloadRaw: unknown; + payloadNormalized: { + threadExternalId: string | null; + contactExternalId: string | null; + text: string | null; + businessConnectionId: string | null; + [key: string]: unknown; + }; +}; + +export type JsonObject = Record; diff --git a/omni_inbound/tsconfig.json b/omni_inbound/tsconfig.json new file mode 100644 index 0000000..5541a4b --- /dev/null +++ b/omni_inbound/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"], + "resolveJsonModule": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/delivery/.dockerignore b/omni_outbound/.dockerignore similarity index 100% rename from delivery/.dockerignore rename to omni_outbound/.dockerignore diff --git a/delivery/Dockerfile b/omni_outbound/Dockerfile similarity index 100% rename from delivery/Dockerfile rename to omni_outbound/Dockerfile diff --git a/omni_outbound/README.md b/omni_outbound/README.md new file mode 100644 index 0000000..a38eb37 --- /dev/null +++ b/omni_outbound/README.md @@ -0,0 +1,16 @@ +# omni_outbound + +Изолированный сервис исходящей доставки. + +## Назначение + +- потребляет задачи из `sender.flow`; +- выполняет отправку в провайдеров; +- применяет retry/backoff и финальный fail-status. + +## Переменные окружения + +- `REDIS_URL` +- `DATABASE_URL` +- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`) +- `OUTBOUND_DELIVERY_QUEUE_NAME` (legacy alias, optional) diff --git a/delivery/package-lock.json b/omni_outbound/package-lock.json similarity index 99% rename from delivery/package-lock.json rename to omni_outbound/package-lock.json index 22fd6f9..a4ddda4 100644 --- a/delivery/package-lock.json +++ b/omni_outbound/package-lock.json @@ -1,10 +1,10 @@ { - "name": "crm-delivery", + "name": "crm-omni-outbound", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "crm-delivery", + "name": "crm-omni-outbound", "dependencies": { "@prisma/client": "^6.16.1", "bullmq": "^5.58.2", diff --git a/delivery/package.json b/omni_outbound/package.json similarity index 92% rename from delivery/package.json rename to omni_outbound/package.json index 6d455d8..2d7b5db 100644 --- a/delivery/package.json +++ b/omni_outbound/package.json @@ -1,5 +1,5 @@ { - "name": "crm-delivery", + "name": "crm-omni-outbound", "private": true, "type": "module", "scripts": { diff --git a/delivery/prisma/schema.prisma b/omni_outbound/prisma/schema.prisma similarity index 100% rename from delivery/prisma/schema.prisma rename to omni_outbound/prisma/schema.prisma diff --git a/delivery/src/queues/outboundDelivery.ts b/omni_outbound/src/queues/outboundDelivery.ts similarity index 97% rename from delivery/src/queues/outboundDelivery.ts rename to omni_outbound/src/queues/outboundDelivery.ts index 76c8b71..70014b3 100644 --- a/delivery/src/queues/outboundDelivery.ts +++ b/omni_outbound/src/queues/outboundDelivery.ts @@ -2,7 +2,11 @@ import { Queue, Worker, type Job, type JobsOptions, type ConnectionOptions } fro import { Prisma } from "@prisma/client"; import { prisma } from "../utils/prisma"; -export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound"; +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; diff --git a/delivery/src/utils/prisma.ts b/omni_outbound/src/utils/prisma.ts similarity index 100% rename from delivery/src/utils/prisma.ts rename to omni_outbound/src/utils/prisma.ts diff --git a/delivery/src/utils/redis.ts b/omni_outbound/src/utils/redis.ts similarity index 100% rename from delivery/src/utils/redis.ts rename to omni_outbound/src/utils/redis.ts diff --git a/delivery/src/worker.ts b/omni_outbound/src/worker.ts similarity index 70% rename from delivery/src/worker.ts rename to omni_outbound/src/worker.ts index 446aa65..5db933b 100644 --- a/delivery/src/worker.ts +++ b/omni_outbound/src/worker.ts @@ -1,12 +1,12 @@ -import { startOutboundDeliveryWorker } from "./queues/outboundDelivery"; +import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./queues/outboundDelivery"; import { prisma } from "./utils/prisma"; import { getRedis } from "./utils/redis"; const worker = startOutboundDeliveryWorker(); -console.log("[delivery-worker] started queue omni:outbound"); +console.log(`[omni_outbound] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`); async function shutdown(signal: string) { - console.log(`[delivery-worker] shutting down by ${signal}`); + console.log(`[omni_outbound] shutting down by ${signal}`); try { await worker.close(); } catch { diff --git a/omni_outbound/tsconfig.json b/omni_outbound/tsconfig.json new file mode 100644 index 0000000..5541a4b --- /dev/null +++ b/omni_outbound/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"], + "resolveJsonModule": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/research/chatwoot/.all-contributorsrc b/research/chatwoot/.all-contributorsrc new file mode 100644 index 0000000..5c3e48c --- /dev/null +++ b/research/chatwoot/.all-contributorsrc @@ -0,0 +1,79 @@ +{ + "files": [ + "docs/contributors.md" + ], + "imageSize": 48, + "commit": false, + "contributors": [ + { + "login": "nithindavid", + "name": "Nithin David Thomas", + "avatar_url": "https://avatars2.githubusercontent.com/u/1277421?v=4", + "profile": "http://nithindavid.me", + "contributions": [ + "bug", + "blog", + "code", + "doc", + "design", + "maintenance", + "review" + ] + } + { + "login": "sojan-official", + "name": "Sojan Jose", + "avatar_url": "https://avatars1.githubusercontent.com/u/73185?v=4", + "profile": "http://sojan.me", + "contributions": [ + "bug", + "blog", + "code", + "doc", + "design", + "maintenance", + "review" + ] + }, + { + "login": "pranavrajs", + "name": "Pranav Raj S", + "avatar_url": "https://avatars3.githubusercontent.com/u/2246121?v=4", + "profile": "https://github.com/pranavrajs", + "contributions": [ + "bug", + "blog", + "code", + "doc", + "design", + "maintenance", + "review" + ] + }, + { + "login": "subintp", + "name": "Subin T P", + "avatar_url": "https://avatars1.githubusercontent.com/u/1742357?v=4", + "profile": "http://www.linkedin.com/in/subintp", + "contributions": [ + "bug", + "code" + ] + }, + { + "login": "manojmj92", + "name": "Manoj M J", + "avatar_url": "https://avatars1.githubusercontent.com/u/4034241?v=4", + "profile": "https://github.com/manojmj92", + "contributions": [ + "bug", + "code", + ] + } + ], + "contributorsPerLine": 7, + "projectName": "chatwoot", + "projectOwner": "chatwoot", + "repoType": "github", + "repoHost": "https://github.com" +} diff --git a/research/chatwoot/.browserslistrc b/research/chatwoot/.browserslistrc new file mode 100644 index 0000000..e94f814 --- /dev/null +++ b/research/chatwoot/.browserslistrc @@ -0,0 +1 @@ +defaults diff --git a/research/chatwoot/.bundler-audit.yml b/research/chatwoot/.bundler-audit.yml new file mode 100644 index 0000000..afe8702 --- /dev/null +++ b/research/chatwoot/.bundler-audit.yml @@ -0,0 +1,3 @@ +--- +ignore: + - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) diff --git a/research/chatwoot/.circleci/config.yml b/research/chatwoot/.circleci/config.yml new file mode 100644 index 0000000..99ac1c2 --- /dev/null +++ b/research/chatwoot/.circleci/config.yml @@ -0,0 +1,374 @@ +version: 2.1 +orbs: + node: circleci/node@6.1.0 + qlty-orb: qltysh/qlty-orb@0.0 + +# Shared defaults for setup steps +defaults: &defaults + working_directory: ~/build + machine: + image: ubuntu-2204:2024.05.1 + resource_class: large + environment: + RAILS_LOG_TO_STDOUT: false + COVERAGE: true + LOG_LEVEL: warn + +jobs: + # Separate job for linting (no parallelism needed) + lint: + <<: *defaults + steps: + - checkout + + # Install minimal system dependencies for linting + - run: + name: Install System Dependencies + command: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.4.4 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 + + - run: + name: Install Application Dependencies + command: | + source ~/.rvm/scripts/rvm + bundle install + + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + # Swagger verification + - run: + name: Verify swagger API specification + command: | + bundle exec rake swagger:build + if [[ `git status swagger/swagger.json --porcelain` ]] + then + echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." + exit 1 + fi + mkdir -p ~/tmp + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar + java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + + # Bundle audit + - run: + name: Bundle audit + command: bundle exec bundle audit update && bundle exec bundle audit check -v + + # Rubocop linting + - run: + name: Rubocop + command: bundle exec rubocop --parallel + + # ESLint linting + - run: + name: eslint + command: pnpm run eslint + + # Separate job for frontend tests + frontend-tests: + <<: *defaults + steps: + - checkout + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + - run: + name: Run frontend tests (with coverage) + command: pnpm run test:coverage + + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/frontend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Backend tests with parallelization + backend-tests: + <<: *defaults + parallelism: 18 + steps: + - checkout + - node/install: + node-version: '24.13' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + - run: + name: Add PostgreSQL repository and update + command: | + sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + sudo apt-get update -y + + - run: + name: Install System Dependencies + command: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + redis-server \ + postgresql-common \ + postgresql-16 \ + postgresql-16-pgvector \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.4.4 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 + + - run: + name: Install Application Dependencies + command: | + source ~/.rvm/scripts/rvm + bundle install + + # Install and configure OpenSearch + - run: + name: Install OpenSearch + command: | + # Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients) + wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz + tar -xzf opensearch-2.11.0-linux-x64.tar.gz + sudo mv opensearch-2.11.0 /opt/opensearch + + - run: + name: Configure and Start OpenSearch + command: | + # Configure OpenSearch for single-node testing + cat > /opt/opensearch/config/opensearch.yml \<< EOF + cluster.name: chatwoot-test + node.name: node-1 + network.host: 0.0.0.0 + http.port: 9200 + discovery.type: single-node + plugins.security.disabled: true + EOF + + # Set ownership and permissions + sudo chown -R $USER:$USER /opt/opensearch + + # Start OpenSearch in background + /opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid + + - run: + name: Wait for OpenSearch to be ready + command: | + echo "Waiting for OpenSearch to start..." + for i in {1..30}; do + if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then + echo "OpenSearch is ready!" + exit 0 + fi + echo "Waiting... ($i/30)" + sleep 2 + done + echo "OpenSearch failed to start" + exit 1 + + # Configure environment and database + - run: + name: Database Setup and Configure Environment Variables + command: | + pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '') + sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql + chmod 644 ${PWD}/.circleci/setup_chatwoot.sql + mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/ + sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql + cp .env.example .env + sed -i '/^FRONTEND_URL/d' .env + sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env + sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env + sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env + sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env + echo -en "\nINSTALLATION_ENV=circleci" >> ".env" + echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env" + + # Database setup + - run: + name: Run DB migrations + command: bundle exec rails db:chatwoot_prepare + + # Run backend tests (parallelized) + - run: + name: Run backend tests + command: | + mkdir -p ~/tmp/test-results/rspec + mkdir -p ~/tmp/test-artifacts + mkdir -p ~/build/coverage/backend + + # Use round-robin distribution (same as GitHub Actions) for better test isolation + # This prevents tests with similar timing from being grouped on the same runner + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + + bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ + --format RspecJunitFormatter \ + --out ~/tmp/test-results/rspec.xml \ + -- $TESTS + no_output_timeout: 30m + + # Store test results for better splitting in future runs + - store_test_results: + path: ~/tmp/test-results + + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/backend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Collect coverage from all jobs + coverage: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/build + + # Qlty coverage publish + - qlty-orb/coverage_publish: + files: | + coverage/frontend/lcov.info + + - run: + name: List coverage directory contents + command: | + ls -R ~/build/coverage || echo "No coverage directory" + + - store_artifacts: + path: coverage + destination: coverage + + build: + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." + +workflows: + version: 2 + build: + jobs: + - lint + - frontend-tests + - backend-tests + - coverage: + requires: + - frontend-tests + - backend-tests + - build: + requires: + - lint + - coverage diff --git a/research/chatwoot/.circleci/setup_chatwoot.sql b/research/chatwoot/.circleci/setup_chatwoot.sql new file mode 100644 index 0000000..4e5430f --- /dev/null +++ b/research/chatwoot/.circleci/setup_chatwoot.sql @@ -0,0 +1,11 @@ +CREATE USER chatwoot CREATEDB; +ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD'; +ALTER ROLE chatwoot SUPERUSER; + +UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1'; +DROP DATABASE template1; +CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE'; +UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1'; + +\c template1; +VACUUM FREEZE; diff --git a/research/chatwoot/.dependabot/config.yml b/research/chatwoot/.dependabot/config.yml new file mode 100644 index 0000000..7069386 --- /dev/null +++ b/research/chatwoot/.dependabot/config.yml @@ -0,0 +1,23 @@ +version: 1 +update_configs: + - package_manager: "ruby:bundler" + directory: "/" + update_schedule: "weekly" + default_reviewers: + - "sony-mathew" + - "sojan-official" + - "subintp" + default_labels: + - "dependencies" + - "ruby" + version_requirement_updates: "auto" + - package_manager: "javascript" + directory: "/" + update_schedule: "weekly" + default_reviewers: + - "pranavrajs" + - "nithindavid" + default_labels: + - "dependencies" + - "javascript" + version_requirement_updates: "auto" diff --git a/research/chatwoot/.devcontainer/Dockerfile b/research/chatwoot/.devcontainer/Dockerfile new file mode 100644 index 0000000..9e8c36f --- /dev/null +++ b/research/chatwoot/.devcontainer/Dockerfile @@ -0,0 +1,18 @@ +# The below image is created out of the Dockerfile.base +# It has the dependencies already installed so that codespace will boot up fast +FROM ghcr.io/chatwoot/chatwoot_codespace:latest + +# Do the set up required for chatwoot app +WORKDIR /workspace + +# Copy dependency files first for better caching +COPY package.json pnpm-lock.yaml ./ +COPY Gemfile Gemfile.lock ./ + +# Install dependencies (will be cached if files don't change) +RUN pnpm install --frozen-lockfile && \ + gem install bundler && \ + bundle install --jobs=$(nproc) + +# Copy source code after dependencies are installed +COPY . /workspace diff --git a/research/chatwoot/.devcontainer/Dockerfile.base b/research/chatwoot/.devcontainer/Dockerfile.base new file mode 100644 index 0000000..dc7d4eb --- /dev/null +++ b/research/chatwoot/.devcontainer/Dockerfile.base @@ -0,0 +1,98 @@ +ARG VARIANT="ubuntu-22.04" + +FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} + +ENV DEBIAN_FRONTEND=noninteractive + +ARG NODE_VERSION +ARG RUBY_VERSION +ARG USER_UID +ARG USER_GID +ARG PNPM_VERSION="10.2.0" +ENV PNPM_VERSION ${PNPM_VERSION} +ENV RUBY_CONFIGURE_OPTS=--disable-install-doc + +# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. +RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ + groupmod --gid $USER_GID vscode \ + && usermod --uid $USER_UID --gid $USER_GID vscode \ + && chmod -R $USER_UID:$USER_GID /home/vscode; \ + fi + +RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ + && curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ + && apt-get update \ + && apt-get -y install --no-install-recommends \ + build-essential \ + libssl-dev \ + zlib1g-dev \ + gnupg \ + tar \ + tzdata \ + postgresql-client \ + libpq-dev \ + git \ + imagemagick \ + libyaml-dev \ + curl \ + ca-certificates \ + tmux \ + nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# Install rbenv and ruby for root user first +RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ + && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ + && echo 'eval "$(rbenv init -)"' >> ~/.bashrc +ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" +RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \ + PREFIX=/usr/local ./ruby-build/install.sh + +RUN rbenv install $RUBY_VERSION && \ + rbenv global $RUBY_VERSION && \ + rbenv versions + +# Set up rbenv for vscode user +RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \ + && su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \ + && su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \ + && su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION" + +# Install overmind and gh in single layer +RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ + && gunzip overmind.gz \ + && mv overmind /usr/local/bin \ + && chmod +x /usr/local/bin/overmind \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + + +# Do the set up required for chatwoot app +WORKDIR /workspace +RUN chown vscode:vscode /workspace + +# set up node js, pnpm and claude code in single layer +RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ + && npm cache clean --force + +# Switch to vscode user +USER vscode +ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" + +# Copy dependency files first for better caching +COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./ + +# Install dependencies as vscode user +RUN eval "$(rbenv init -)" \ + && gem install bundler -N \ + && bundle install --jobs=$(nproc) \ + && pnpm install --frozen-lockfile + +# Copy source code after dependencies are installed +COPY --chown=vscode:vscode . /workspace diff --git a/research/chatwoot/.devcontainer/devcontainer.json b/research/chatwoot/.devcontainer/devcontainer.json new file mode 100644 index 0000000..2e237bb --- /dev/null +++ b/research/chatwoot/.devcontainer/devcontainer.json @@ -0,0 +1,49 @@ +{ + "name": "Chatwoot Development Codespace", + "service": "app", + "dockerComposeFile": "docker-compose.yml", + + "settings": { + "terminal.integrated.shell.linux": "/bin/zsh", + "extensions.showRecommendationsOnlyOnDemand": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "search.exclude": { + "**/node_modules": true, + "**/tmp": true, + "**/log": true, + "**/coverage": true, + "**/public/packs": true + } + }, + + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "Shopify.ruby-lsp", + "misogi.ruby-rubocop", + "davidpallinder.rails-test-runner", + "github.copilot", + "mrmlnc.vscode-duplicate" + ], + + + // 5432 postgres + // 6379 redis + // 1025,8025 mailhog + "forwardPorts": [8025, 3000, 3036], + + "postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install", + "portsAttributes": { + "3000": { + "label": "Rails Server" + }, + "3036": { + "label": "Vite Dev Server" + }, + "8025": { + "label": "Mailhog UI" + } + } +} diff --git a/research/chatwoot/.devcontainer/docker-compose.base.yml b/research/chatwoot/.devcontainer/docker-compose.base.yml new file mode 100644 index 0000000..375742f --- /dev/null +++ b/research/chatwoot/.devcontainer/docker-compose.base.yml @@ -0,0 +1,18 @@ +# Docker Compose file for building the base image in GitHub Actions +# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base + +version: '3' + +services: + base: + build: + context: .. + dockerfile: .devcontainer/Dockerfile.base + args: + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '24.13.0' + RUBY_VERSION: '3.4.4' + # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. + USER_UID: '1000' + USER_GID: '1000' + image: ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/research/chatwoot/.devcontainer/docker-compose.yml b/research/chatwoot/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..d696f99 --- /dev/null +++ b/research/chatwoot/.devcontainer/docker-compose.yml @@ -0,0 +1,53 @@ +# https://github.com/microsoft/vscode-dev-containers/blob/master/containers/python-3-postgres/.devcontainer/docker-compose.yml +# https://github.com/microsoft/vscode-dev-containers/blob/master/containers/ruby-rails/.devcontainer/devcontainer.json +# + +version: '3' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + args: + VARIANT: 'ubuntu-22.04' + NODE_VERSION: '24.13.0' + RUBY_VERSION: '3.4.4' + # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. + USER_UID: '1000' + USER_GID: '1000' + + volumes: + - ..:/workspace:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + db: + image: pgvector/pgvector:pg16 + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + redis: + image: redis:latest + restart: unless-stopped + network_mode: service:db + volumes: + - redis-data:/data + + mailhog: + restart: unless-stopped + image: mailhog/mailhog + network_mode: service:db + +volumes: + postgres-data: + redis-data: diff --git a/research/chatwoot/.devcontainer/scripts/setup.sh b/research/chatwoot/.devcontainer/scripts/setup.sh new file mode 100755 index 0000000..36db5cf --- /dev/null +++ b/research/chatwoot/.devcontainer/scripts/setup.sh @@ -0,0 +1,16 @@ +cp .env.example .env +sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env +sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env +sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env +sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env + +# Setup Claude Code API key if available +if [ -n "$CLAUDE_CODE_API_KEY" ]; then + mkdir -p ~/.claude + echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json + echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh + chmod +x ~/.claude/anthropic_key.sh +fi + +# codespaces make the ports public +gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME diff --git a/research/chatwoot/.dockerignore b/research/chatwoot/.dockerignore new file mode 100644 index 0000000..a5af77e --- /dev/null +++ b/research/chatwoot/.dockerignore @@ -0,0 +1,17 @@ +.bundle +.env +.env.* +docker-compose.* +docker/Dockerfile +docker/dockerfiles +log +storage +public/system +tmp +.codeclimate.yml +public/packs +node_modules +vendor/bundle +.DS_Store +*.swp +*~ diff --git a/research/chatwoot/.editorconfig b/research/chatwoot/.editorconfig new file mode 100644 index 0000000..2a5fe28 --- /dev/null +++ b/research/chatwoot/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs +# @see http://editorconfig.org +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +tab_width = 2 + +[*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}] +indent_size = 2 diff --git a/research/chatwoot/.env.example b/research/chatwoot/.env.example new file mode 100644 index 0000000..bc7380a --- /dev/null +++ b/research/chatwoot/.env.example @@ -0,0 +1,279 @@ +# Learn about the various environment variables at +# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables + +# Used to verify the integrity of signed cookies. so ensure a secure value is set +# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. +# Use `rake secret` to generate this variable +SECRET_KEY_BASE=replace_with_lengthy_secure_hex + +# Active Record Encryption keys (required for MFA/2FA functionality) +# Generate these keys by running: rails db:encryption:init +# IMPORTANT: Use different keys for each environment (development, staging, production) +# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY= +# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY= +# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT= + +# Replace with the URL you are planning to use for your app +FRONTEND_URL=http://0.0.0.0:3000 +# To use a dedicated URL for help center pages +# HELPCENTER_URL=http://0.0.0.0:3000 + +# If the variable is set, all non-authenticated pages would fallback to the default locale. +# Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en +# DEFAULT_LOCALE=en + +# If you plan to use CDN for your assets, set Asset CDN Host +ASSET_CDN_HOST= + +# Force all access to the app over SSL, default is set to false +FORCE_SSL=false + +# This lets you control new sign ups on your chatwoot installation +# true : default option, allows sign ups +# false : disables all the end points related to sign ups +# api_only: disables the UI for signup, but you can create sign ups via the account apis +ENABLE_ACCOUNT_SIGNUP=false + +# Redis config +# specify the configs via single URL or individual variables +# ref: https://www.iana.org/assignments/uri-schemes/prov/redis +# You can also use the following format for the URL: redis://:password@host:port/db_number +REDIS_URL=redis://redis:6379 +# If you are using docker-compose, set this variable's value to be any string, +# which will be the password for the redis service running inside the docker-compose +# to make it secure +REDIS_PASSWORD= +# Redis Sentinel can be used by passing list of sentinel host and ports e,g. sentinel_host1:port1,sentinel_host2:port2 +REDIS_SENTINELS= +# Redis sentinel master name is required when using sentinel, default value is "mymaster". +# You can find list of master using "SENTINEL masters" command +REDIS_SENTINEL_MASTER_NAME= + +# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels +# Use the following environment variable to customize passwords for sentinels. +# Use empty string if sentinels are configured with out passwords +# REDIS_SENTINEL_PASSWORD= + +# Redis premium breakage in heroku fix +# enable the following configuration +# ref: https://github.com/chatwoot/chatwoot/issues/2420 +# REDIS_OPENSSL_VERIFY_MODE=none + +# Postgres Database config variables +# You can leave POSTGRES_DATABASE blank. The default name of +# the database in the production environment is chatwoot_production +# POSTGRES_DATABASE= +POSTGRES_HOST=postgres +POSTGRES_USERNAME=postgres +POSTGRES_PASSWORD= +RAILS_ENV=development +# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required. +# POSTGRES_STATEMENT_TIMEOUT=14s +RAILS_MAX_THREADS=5 + +# The email from which all outgoing emails are sent +# could user either `email@yourdomain.com` or `BrandName ` +MAILER_SENDER_EMAIL=Chatwoot + +#SMTP domain key is set up for HELO checking +SMTP_DOMAIN=chatwoot.com +# Set the value to "mailhog" if using docker-compose for development environments, +# Set the value as "localhost" or your SMTP address in other environments +# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix) +SMTP_ADDRESS= +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +# plain,login,cram_md5 +SMTP_AUTHENTICATION= +SMTP_ENABLE_STARTTLS_AUTO=true +# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html +SMTP_OPENSSL_VERIFY_MODE=peer +# Comment out the following environment variables if required by your SMTP server +# SMTP_TLS= +# SMTP_SSL= +# SMTP_OPEN_TIMEOUT +# SMTP_READ_TIMEOUT + +# Mail Incoming +# This is the domain set for the reply emails when conversation continuity is enabled +MAILER_INBOUND_EMAIL_DOMAIN= +# Set this to the appropriate ingress channel with regards to incoming emails +# Possible values are : +# relay for Exim, Postfix, Qmail +# mailgun for Mailgun +# mandrill for Mandrill +# postmark for Postmark +# sendgrid for Sendgrid +# ses for Amazon SES +RAILS_INBOUND_EMAIL_SERVICE= +# Use one of the following based on the email ingress service +# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html +# Set this to a password of your choice and use it in the Inbound webhook +RAILS_INBOUND_EMAIL_PASSWORD= + +MAILGUN_INGRESS_SIGNING_KEY= +MANDRILL_INGRESS_API_KEY= + +# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name) +# Configure only if the rails_inbound_email_service = ses +ACTION_MAILBOX_SES_SNS_TOPIC= + +# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: +# Inbound webhook URL format: +# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails +# Note: Replace the values inside the brackets; do not include the brackets themselves. +# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails +# For Postmark +# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section. + +# Storage +ACTIVE_STORAGE_SERVICE=local + +# Amazon S3 +# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage +S3_BUCKET_NAME= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= + +# Log settings +# Disable if you want to write logs to a file +RAILS_LOG_TO_STDOUT=true +LOG_LEVEL=info +LOG_SIZE=500 +# Configure this environment variable if you want to use lograge instead of rails logger +#LOGRAGE_ENABLED=true + +### This environment variables are only required if you are setting up social media channels + +# Facebook +# documentation: https://www.chatwoot.com/docs/facebook-setup +FB_VERIFY_TOKEN= +FB_APP_SECRET= +FB_APP_ID= + +# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard +IG_VERIFY_TOKEN= + +# Twitter +# documentation: https://www.chatwoot.com/docs/twitter-app-setup +TWITTER_APP_ID= +TWITTER_CONSUMER_KEY= +TWITTER_CONSUMER_SECRET= +TWITTER_ENVIRONMENT= + +#slack integration +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= + +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_CALLBACK_URL= + +### Change this env variable only if you are using a custom build mobile app +## Mobile app env variables +IOS_APP_ID=L7YLMN4634.com.chatwoot.app +ANDROID_BUNDLE_ID=com.chatwoot.app + +# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section) +ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8 + +### Smart App Banner +# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html +# You can find your app-id in https://itunesconnect.apple.com +#IOS_APP_IDENTIFIER=1495796682 + +## Push Notification +## generate a new key value here : https://d3v.one/vapid-key-generator/ +# VAPID_PUBLIC_KEY= +# VAPID_PRIVATE_KEY= +# +# for mobile apps +# FCM_SERVER_KEY= + +### APM and Error Monitoring configurations +## Elastic APM +## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html +# ELASTIC_APM_SERVER_URL= +# ELASTIC_APM_SECRET_TOKEN= + +## Sentry +# SENTRY_DSN= + + +## Scout +## https://scoutapm.com/docs/ruby/configuration +# SCOUT_KEY=YOURKEY +# SCOUT_NAME=YOURAPPNAME (Production) +# SCOUT_MONITOR=true + +## NewRelic +# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ +# NEW_RELIC_LICENSE_KEY= +# Set this to true to allow newrelic apm to send logs. +# This is turned off by default. +# NEW_RELIC_APPLICATION_LOGGING_ENABLED= + +## Datadog +## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables +# DD_TRACE_AGENT_URL= + + +# MaxMindDB API key to download GeoLite2 City database +# IP_LOOKUP_API_KEY= + +## Rack Attack configuration +## To prevent and throttle abusive requests +# ENABLE_RACK_ATTACK=true +# RACK_ATTACK_LIMIT=300 +# ENABLE_RACK_ATTACK_WIDGET_API=true +# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules +# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 + +## Running chatwoot as an API only server +## setting this value to true will disable the frontend dashboard endpoints +# CW_API_ONLY_SERVER=false + +## Development Only Config +# if you want to use letter_opener for local emails +# LETTER_OPENER=true +# meant to be used in github codespaces +# WEBPACKER_DEV_SERVER_PUBLIC= + +# If you want to use official mobile app, +# the notifications would be relayed via a Chatwoot server +ENABLE_PUSH_RELAY_SERVER=true + +# Stripe API key +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# Set to true if you want to upload files to cloud storage using the signed url +# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true. +DIRECT_UPLOADS_ENABLED= + +#MS OAUTH creds +AZURE_APP_ID= +AZURE_APP_SECRET= + +## Advanced configurations +## Change these values to fine tune performance +# control the concurrency setting of sidekiq +# SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false + + +# AI powered features +## OpenAI key +# OPENAI_API_KEY= + +# Housekeeping/Performance related configurations +# Set to true if you want to remove stale contact inboxes +# contact_inboxes with no conversation older than 90 days will be removed +# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false + +# REDIS_ALFRED_SIZE=10 +# REDIS_VELMA_SIZE=10 diff --git a/research/chatwoot/.eslintrc.js b/research/chatwoot/.eslintrc.js new file mode 100644 index 0000000..6b5205a --- /dev/null +++ b/research/chatwoot/.eslintrc.js @@ -0,0 +1,256 @@ +module.exports = { + extends: [ + 'airbnb-base/legacy', + 'prettier', + 'plugin:vue/vue3-recommended', + 'plugin:vitest-globals/recommended', + // use recommended-legacy when upgrading the plugin to v4 + 'plugin:@intlify/vue-i18n/recommended', + ], + overrides: [ + { + files: ['**/*.spec.{j,t}s?(x)'], + env: { + 'vitest-globals/env': true, + }, + }, + { + files: ['**/*.story.vue'], + rules: { + 'vue/no-undef-components': [ + 'error', + { + ignorePatterns: ['Variant', 'Story'], + }, + ], + // Story files can have static strings, it doesn't need to handle i18n always. + 'vue/no-bare-strings-in-template': 'off', + 'no-console': 'off', + }, + }, + ], + plugins: ['html', 'prettier'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + rules: { + 'prettier/prettier': ['error'], + camelcase: 'off', + 'no-param-reassign': 'off', + 'import/no-extraneous-dependencies': 'off', + 'import/prefer-default-export': 'off', + 'import/no-named-as-default': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + 'jsx-a11y/label-has-associated-control': 'off', + 'jsx-a11y/label-has-for': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'import/no-unresolved': 'off', + 'vue/html-indent': 'off', + 'vue/multi-word-component-names': 'off', + 'vue/next-tick-style': ['error', 'callback'], + 'vue/block-order': [ + 'error', + { + order: ['script', 'template', 'style'], + }, + ], + 'vue/component-name-in-template-casing': [ + 'error', + 'PascalCase', + { + registeredComponentsOnly: true, + }, + ], + 'vue/component-options-name-casing': ['error', 'PascalCase'], + 'vue/custom-event-name-casing': ['error', 'camelCase'], + 'vue/define-emits-declaration': ['error'], + 'vue/define-macros-order': [ + 'error', + { + order: ['defineProps', 'defineEmits'], + defineExposeLast: false, + }, + ], + 'vue/define-props-declaration': ['error', 'runtime'], + 'vue/match-component-import-name': ['error'], + 'vue/no-bare-strings-in-template': [ + 'error', + { + allowlist: [ + '(', + ')', + ',', + '.', + '&', + '+', + '-', + '=', + '*', + '/', + '#', + '%', + '!', + '?', + ':', + '[', + ']', + '{', + '}', + '<', + '>', + '⌘', + '📄', + '🎉', + '🚀', + '💬', + '👥', + '📥', + '🔖', + '❌', + '✅', + '\u00b7', + '\u2022', + '\u2010', + '\u2013', + '\u2014', + '\u2212', + '|', + ], + attributes: { + '/.+/': [ + 'title', + 'aria-label', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + ], + input: ['placeholder'], + }, + directives: ['v-text'], + }, + ], + 'vue/no-empty-component-block': 'error', + 'vue/no-multiple-objects-in-class': 'error', + 'vue/no-root-v-if': 'warn', + 'vue/no-static-inline-styles': [ + 'error', + { + allowBinding: false, + }, + ], + 'vue/no-template-target-blank': [ + 'error', + { + allowReferrer: false, + enforceDynamicLinks: 'always', + }, + ], + 'vue/no-required-prop-with-default': [ + 'error', + { + autofix: false, + }, + ], + 'vue/no-this-in-before-route-enter': 'error', + 'vue/no-undef-components': [ + 'error', + { + ignorePatterns: [ + '^woot-', + '^fluent-', + '^multiselect', + '^router-link', + '^router-view', + '^ninja-keys', + '^FormulateForm', + '^FormulateInput', + '^highlightjs', + ], + }, + ], + 'vue/no-unused-emit-declarations': 'error', + 'vue/no-unused-refs': 'error', + 'vue/no-use-v-else-with-v-for': 'error', + 'vue/prefer-true-attribute-shorthand': 'error', + 'vue/no-useless-v-bind': [ + 'error', + { + ignoreIncludesComment: false, + ignoreStringEscape: false, + }, + ], + 'vue/no-v-text': 'error', + 'vue/padding-line-between-blocks': ['error', 'always'], + 'vue/prefer-separate-static-class': 'error', + 'vue/require-explicit-slots': 'error', + 'vue/require-macro-variable-name': [ + 'error', + { + defineProps: 'props', + defineEmits: 'emit', + defineSlots: 'slots', + useSlots: 'slots', + useAttrs: 'attrs', + }, + ], + 'vue/no-unused-properties': [ + 'error', + { + groups: ['props'], + deepData: false, + ignorePublicMembers: false, + unreferencedOptions: [], + }, + ], + 'vue/max-attributes-per-line': [ + 'error', + { + singleline: { + max: 20, + }, + multiline: { + max: 1, + }, + }, + ], + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'always', + component: 'always', + }, + svg: 'always', + math: 'always', + }, + ], + 'vue/no-v-html': 'off', + 'vue/component-definition-name-casing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'import/extensions': ['off'], + 'no-console': 'error', + '@intlify/vue-i18n/no-dynamic-keys': 'warn', + '@intlify/vue-i18n/no-unused-keys': [ + 'warn', + { + extensions: ['.js', '.vue'], + }, + ], + }, + settings: { + 'vue-i18n': { + localeDir: './app/javascript/*/i18n/**.json', + }, + }, + env: { + browser: true, + node: true, + }, + globals: { + bus: true, + vi: true, + }, +}; diff --git a/research/chatwoot/.github/CODEOWNERS b/research/chatwoot/.github/CODEOWNERS new file mode 100644 index 0000000..499e5c1 --- /dev/null +++ b/research/chatwoot/.github/CODEOWNERS @@ -0,0 +1,2 @@ +## All enterprise related files should be reviewed by sojan before merging +/enterprise/* @sojan-official diff --git a/research/chatwoot/.github/FUNDING.yml b/research/chatwoot/.github/FUNDING.yml new file mode 100644 index 0000000..48a02cf --- /dev/null +++ b/research/chatwoot/.github/FUNDING.yml @@ -0,0 +1,2 @@ +open_collective: chatwoot +github: chatwoot diff --git a/research/chatwoot/.github/ISSUE_TEMPLATE/bug_report.yml b/research/chatwoot/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a0c213d --- /dev/null +++ b/research/chatwoot/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,78 @@ +name: 🐞 Bug report +description: Create a report to help us improve +labels: 'Bug' +body: + - type: textarea + attributes: + label: Describe the bug + description: A concise description of what you expected to happen along with screenshots if applicable. + validations: + required: true + - type: textarea + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: A concise description of what you expected to happen. + - type: dropdown + id: environment + attributes: + label: Environment + description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other). + options: + - app.chatwoot.com + - Linux VM + - Docker + - Kubernetes + - Heroku + - Other [please specify in the description] + validations: + required: true + - type: dropdown + id: provider + attributes: + label: Cloud Provider + description: + options: + - AWS + - GCP + - Azure + - DigitalOcean + - Other [please specify in the description] + - type: dropdown + id: platform + attributes: + label: Platform + description: Describe the platform you are using + options: + - Browser + - Mobile + - type: input + attributes: + label: Operating system + description: The operating system and the version you are using. + - type: input + attributes: + label: Browser and version + description: The name of the browser and version you are using. + - type: textarea + attributes: + label: Docker (if applicable) + description: | + Please share the output of the following. + - `docker version` + - `docker info` + - `docker-compose version` + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. diff --git a/research/chatwoot/.github/ISSUE_TEMPLATE/config.yml b/research/chatwoot/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..306f5e4 --- /dev/null +++ b/research/chatwoot/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security issue + url: https://www.chatwoot.com/docs/contributing-guide/security-reports/ + about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here. + - name: Product Documentation + url: https://www.chatwoot.com/help-center + about: If you have questions, are confused, or just want to understand our product better, please check out our documentation. diff --git a/research/chatwoot/.github/ISSUE_TEMPLATE/feature_request.yml b/research/chatwoot/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f549bcc --- /dev/null +++ b/research/chatwoot/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,28 @@ +name: 🧙 Feature request +description: Suggest an idea for this project +labels: 'feature-request' +body: + - type: textarea + attributes: + label: Is your feature or enhancement related to a problem? Please describe. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the feature request here. + validations: + required: false diff --git a/research/chatwoot/.github/PULL_REQUEST_TEMPLATE.md b/research/chatwoot/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cd4d83b --- /dev/null +++ b/research/chatwoot/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,31 @@ +# Pull Request Template + +## Description + +Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) +- [ ] This change requires a documentation update + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. + + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented on my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules diff --git a/research/chatwoot/.github/screenshots/dashboard-dark.png b/research/chatwoot/.github/screenshots/dashboard-dark.png new file mode 100644 index 0000000..4d08b52 Binary files /dev/null and b/research/chatwoot/.github/screenshots/dashboard-dark.png differ diff --git a/research/chatwoot/.github/screenshots/dashboard.png b/research/chatwoot/.github/screenshots/dashboard.png new file mode 100644 index 0000000..b8b99be Binary files /dev/null and b/research/chatwoot/.github/screenshots/dashboard.png differ diff --git a/research/chatwoot/.github/screenshots/header-dark.png b/research/chatwoot/.github/screenshots/header-dark.png new file mode 100644 index 0000000..84931ae Binary files /dev/null and b/research/chatwoot/.github/screenshots/header-dark.png differ diff --git a/research/chatwoot/.github/screenshots/header.png b/research/chatwoot/.github/screenshots/header.png new file mode 100644 index 0000000..f10ca0f Binary files /dev/null and b/research/chatwoot/.github/screenshots/header.png differ diff --git a/research/chatwoot/.github/workflows/auto-assign-pr.yml b/research/chatwoot/.github/workflows/auto-assign-pr.yml new file mode 100644 index 0000000..98df897 --- /dev/null +++ b/research/chatwoot/.github/workflows/auto-assign-pr.yml @@ -0,0 +1,28 @@ +name: Auto-assign PR to Author + +on: + pull_request: + types: [opened] + +jobs: + auto-assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Auto-assign PR to author + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const author = context.payload.pull_request.user.login; + + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: pull_number, + assignees: [author] + }); + + console.log(`Assigned PR #${pull_number} to ${author}`); \ No newline at end of file diff --git a/research/chatwoot/.github/workflows/deploy_check.yml b/research/chatwoot/.github/workflows/deploy_check.yml new file mode 100644 index 0000000..9f295a6 --- /dev/null +++ b/research/chatwoot/.github/workflows/deploy_check.yml @@ -0,0 +1,50 @@ +## github action to check deployment success +## curl the deployment url and check for 200 status +## deployment url will be of the form chatwoot-pr-.herokuapp.com +name: Deploy Check + +on: + pull_request: + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + deployment_check: + name: Check Deployment + runs-on: ubuntu-latest + steps: + - name: Install jq + run: sudo apt-get install -y jq + - name: Print Deployment URL + run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com" + - name: Check Deployment Status + run: | + max_attempts=10 + attempt=1 + status_code=0 + echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..." + sleep 600 + while [ $attempt -le $max_attempts ]; do + response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api) + status_code=$(echo $response | head -n 1) + if [ $status_code -eq 200 ]; then + body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api) + if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then + echo "Deployment successful" + exit 0 + else + echo "Deployment status unknown, retrying in 3 minutes..." + sleep 180 + fi + else + echo "Waiting for review app to be ready, retrying in 3 minutes..." + sleep 180 + attempt=$((attempt + 1)) + fi + done + echo "Deployment failed after $max_attempts attempts" + exit 1 + fi diff --git a/research/chatwoot/.github/workflows/frontend-fe.yml b/research/chatwoot/.github/workflows/frontend-fe.yml new file mode 100644 index 0000000..1d1116d --- /dev/null +++ b/research/chatwoot/.github/workflows/frontend-fe.yml @@ -0,0 +1,41 @@ +name: Frontend Lint & Test + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install pnpm dependencies + run: pnpm install --frozen-lockfile + + - name: Run eslint + run: pnpm run eslint + + - name: Run frontend tests with coverage + run: | + mkdir -p coverage + pnpm run test:coverage diff --git a/research/chatwoot/.github/workflows/lint_pr.yml b/research/chatwoot/.github/workflows/lint_pr.yml new file mode 100644 index 0000000..ec8f6f4 --- /dev/null +++ b/research/chatwoot/.github/workflows/lint_pr.yml @@ -0,0 +1,23 @@ +# ref: https://github.com/amannn/action-semantic-pull-request +# ensure PR title is in semantic format + +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/research/chatwoot/.github/workflows/lock.yml b/research/chatwoot/.github/workflows/lock.yml new file mode 100644 index 0000000..5420e82 --- /dev/null +++ b/research/chatwoot/.github/workflows/lock.yml @@ -0,0 +1,29 @@ +# We often have cases where users would comment over stale closed Github Issues. +# This creates unnecessary noise for the original reporter and makes it harder for triaging. +# This action locks the closed threads once it is inactive for over a month. + +name: 'Lock Threads' + +on: + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +concurrency: + group: lock + +jobs: + action: + runs-on: ubuntu-latest + if: ${{ github.repository == 'chatwoot/chatwoot' }} + steps: + - uses: dessant/lock-threads@v3 + with: + issue-inactive-days: '30' + issue-lock-reason: 'resolved' + pr-inactive-days: '30' + pr-lock-reason: 'resolved' diff --git a/research/chatwoot/.github/workflows/logging_percentage_check.yml b/research/chatwoot/.github/workflows/logging_percentage_check.yml new file mode 100644 index 0000000..5c45ba6 --- /dev/null +++ b/research/chatwoot/.github/workflows/logging_percentage_check.yml @@ -0,0 +1,60 @@ +name: Log Lines Percentage Check + +on: + pull_request: + branches: + - develop + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + log_lines_check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for log lines and calculate percentage + run: | + # Define the log line pattern + LOG_LINE_PATTERN="Rails\.logger" + + # Get the list of changed files in the pull request + CHANGED_FILES=$(git diff --name-only) + + # Initialize a flag to track if any files have insufficient log lines + INSUFFICIENT_LOGS=0 + + for file in $CHANGED_FILES; do + if [[ $file =~ \.rb$ && ! $file =~ _spec\.rb$ ]]; then + # Count the total number of lines in the file + total_lines=$(wc -l < "$file") + + # Count the number of log lines in the file + log_lines=$(grep -c "$LOG_LINE_PATTERN" "$file") + + # Calculate the percentage of log lines + if [ "$total_lines" -gt 0 ]; then + percentage=$(awk "BEGIN { pc=100*${log_lines}/${total_lines}; i=int(pc); print (pc-i<0.5)?i:i+1 }") + else + percentage=0 + fi + + # Check if the percentage is less than 5% + if [ "$percentage" -lt 5 ]; then + echo "Error: Log lines percentage is less than 5% ($percentage%) in $file. Please add more log lines using Rails.logger statements." + INSUFFICIENT_LOGS=1 + else + echo "Log lines percentage is $percentage% in $file. Code looks good!" + fi + fi + done + + # If any files have insufficient log lines, fail the action + if [ "$INSUFFICIENT_LOGS" -eq 1 ]; then + exit 1 + fi diff --git a/research/chatwoot/.github/workflows/nightly_installer.yml b/research/chatwoot/.github/workflows/nightly_installer.yml new file mode 100644 index 0000000..beef572 --- /dev/null +++ b/research/chatwoot/.github/workflows/nightly_installer.yml @@ -0,0 +1,52 @@ +# # +# # +# # Linux nightly installer action +# # This action will try to install and setup +# # chatwoot on an Ubuntu 22.04 machine using +# # the linux installer script. +# # +# # This is set to run daily at midnight. +# # + +name: Run Linux nightly installer +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + nightly: + runs-on: ubuntu-24.04 + steps: + + - name: get installer + run: | + wget https://get.chatwoot.app/linux/install.sh + chmod +x install.sh + #fix for postgtres not starting automatically in gh action env + sed -i '/function configure_db() {/a sudo service postgresql start' install.sh + + - name: create input file + run: | + echo "no" > input + echo "yes" >> input + + - name: Run the installer + run: | + sudo ./install.sh --install < input + + # disabling http verify for now as http + # access to port 3000 fails in gh action env + # - name: Verify + # if: always() + # run: | + # sudo netstat -ntlp | grep 3000 + # sudo systemctl restart chatwoot.target + # curl http://localhost:3000/api + + - name: Upload chatwoot setup log file as an artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: chatwoot-setup-log-file + path: /var/log/chatwoot-setup.log diff --git a/research/chatwoot/.github/workflows/publish_codespace_image.yml b/research/chatwoot/.github/workflows/publish_codespace_image.yml new file mode 100644 index 0000000..5da4fda --- /dev/null +++ b/research/chatwoot/.github/workflows/publish_codespace_image.yml @@ -0,0 +1,23 @@ +name: Publish Codespace Base Image + +on: + workflow_dispatch: + +jobs: + publish-code-space-image: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build the Codespace Base Image + run: | + docker compose -f .devcontainer/docker-compose.base.yml build base + docker push ghcr.io/chatwoot/chatwoot_codespace:latest diff --git a/research/chatwoot/.github/workflows/publish_ee_docker.yml b/research/chatwoot/.github/workflows/publish_ee_docker.yml new file mode 100644 index 0000000..8e2c224 --- /dev/null +++ b/research/chatwoot/.github/workflows/publish_ee_docker.yml @@ -0,0 +1,140 @@ +# # +# # This action will publish Chatwoot EE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot EE docker images + +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +env: + DOCKER_REPO: chatwoot/chatwoot + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}" + fi + + docker buildx imagetools inspect $TAG diff --git a/research/chatwoot/.github/workflows/publish_foss_docker.yml b/research/chatwoot/.github/workflows/publish_foss_docker.yml new file mode 100644 index 0000000..3075a7f --- /dev/null +++ b/research/chatwoot/.github/workflows/publish_foss_docker.yml @@ -0,0 +1,145 @@ +# # +# # This action will publish Chatwoot CE docker image. +# # This is set to run against merges to develop, master +# # and when tags are created. +# # + +name: Publish Chatwoot CE docker images + +on: + push: + branches: + - develop + - master + tags: + - v* + workflow_dispatch: + +env: + DOCKER_REPO: chatwoot/chatwoot + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Set Chatwoot edition + run: | + echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile + + - name: Set Docker Tags + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV + else + echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: + - build + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools create -t $TAG \ + $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) + + - name: Inspect image + env: + GIT_REF: ${{ github.head_ref || github.ref_name }} + run: | + SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') + if [ "${{ github.ref_name }}" = "master" ]; then + TAG="${DOCKER_REPO}:latest-ce" + else + TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" + fi + + docker buildx imagetools inspect $TAG diff --git a/research/chatwoot/.github/workflows/run_foss_spec.yml b/research/chatwoot/.github/workflows/run_foss_spec.yml new file mode 100644 index 0000000..c2a6263 --- /dev/null +++ b/research/chatwoot/.github/workflows/run_foss_spec.yml @@ -0,0 +1,146 @@ +name: Run Chatwoot CE spec +permissions: + contents: read +on: + push: + branches: + - develop + - master + pull_request: + workflow_dispatch: + +jobs: + # Separate linting jobs for faster feedback + lint-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run Rubocop + run: bundle exec rubocop --parallel + + lint-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run ESLint + run: pnpm run eslint + + # Frontend tests run in parallel with backend + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run frontend tests + run: pnpm run test:coverage + + # Backend tests with parallelization + backend-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ci_node_total: [16] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '' + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:alpine + ports: + - 6379:6379 + options: --entrypoint redis-server + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install pnpm dependencies + run: pnpm i + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: Create database + run: bundle exec rake db:create + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Run backend tests (parallelized) + run: | + # Get all spec files and split them using round-robin distribution + # This ensures slow tests are distributed evenly across all nodes + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + # Assign spec to this node if: index % total == node_index + if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + + if [ -n "$TESTS" ]; then + bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS + fi + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: rspec-results-${{ matrix.ci_node_index }} + path: tmp/rspec_results.json + + - name: Upload rails log folder + uses: actions/upload-artifact@v4 + if: failure() + with: + name: rails-log-folder-${{ matrix.ci_node_index }} + path: log diff --git a/research/chatwoot/.github/workflows/run_mfa_spec.yml b/research/chatwoot/.github/workflows/run_mfa_spec.yml new file mode 100644 index 0000000..69d019c --- /dev/null +++ b/research/chatwoot/.github/workflows/run_mfa_spec.yml @@ -0,0 +1,100 @@ +name: Run MFA Tests +permissions: + contents: read + +on: + pull_request: + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + # Only run if MFA test keys are available + if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]') + + services: + postgres: + image: pgvector/pgvector:pg15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '' + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --mount type=tmpfs,destination=/var/lib/postgresql/data + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis + ports: + - 6379:6379 + options: --entrypoint redis-server + + env: + RAILS_ENV: test + POSTGRES_HOST: localhost + # Active Record encryption keys required for MFA - test keys only, not for production use + ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7' + ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8' + ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9' + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Create database + run: bundle exec rake db:create + + - name: Install pgvector extension + run: | + PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;" + + - name: Seed database + run: bundle exec rake db:schema:load + + - name: Run MFA-related backend tests + run: | + bundle exec rspec \ + spec/services/mfa/token_service_spec.rb \ + spec/services/mfa/authentication_service_spec.rb \ + spec/requests/api/v1/profile/mfa_controller_spec.rb \ + spec/controllers/devise_overrides/sessions_controller_spec.rb \ + spec/models/application_record_external_credentials_encryption_spec.rb \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Run MFA-related tests in user_spec + run: | + # Run specific MFA-related tests from user_spec + bundle exec rspec spec/models/user_spec.rb \ + -e "two factor" \ + -e "2FA" \ + -e "MFA" \ + -e "otp" \ + -e "backup code" \ + --profile=10 \ + --format documentation + env: + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload test logs + uses: actions/upload-artifact@v4 + if: failure() + with: + name: mfa-test-logs + path: | + log/test.log + tmp/screenshots/ diff --git a/research/chatwoot/.github/workflows/size-limit.yml b/research/chatwoot/.github/workflows/size-limit.yml new file mode 100644 index 0000000..7869bf8 --- /dev/null +++ b/research/chatwoot/.github/workflows/size-limit.yml @@ -0,0 +1,52 @@ +name: Run Size Limit Check + +on: + pull_request: + branches: + - develop + +# If two pushes happen within a short time in the same PR, cancel the run of the oldest push +concurrency: + group: pr-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'pnpm' + + - name: pnpm + run: pnpm install + + - name: Strip enterprise code + run: | + rm -rf enterprise + rm -rf spec/enterprise + + - name: setup env + run: | + cp .env.example .env + + - name: Run asset compile + run: bundle exec rake assets:precompile + env: + RAILS_ENV: production + + - name: Size Check + run: pnpm run size diff --git a/research/chatwoot/.github/workflows/stale.yml b/research/chatwoot/.github/workflows/stale.yml new file mode 100644 index 0000000..7a7564e --- /dev/null +++ b/research/chatwoot/.github/workflows/stale.yml @@ -0,0 +1,28 @@ +# This workflow warns and then closes PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '28 3 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + days-before-issue-close: -1, + days-before-issue-stale: -1 + days-before-pr-close: -1, + days-before-pr-stale: 30, + stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?' + stale-pr-label: 'stale' diff --git a/research/chatwoot/.github/workflows/test_docker_build.yml b/research/chatwoot/.github/workflows/test_docker_build.yml new file mode 100644 index 0000000..b27d904 --- /dev/null +++ b/research/chatwoot/.github/workflows/test_docker_build.yml @@ -0,0 +1,40 @@ +name: Test Docker Build + +on: + pull_request: + branches: + - develop + - master + workflow_dispatch: + +jobs: + test-build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: false + load: false + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/research/chatwoot/.gitignore b/research/chatwoot/.gitignore new file mode 100644 index 0000000..017c5c2 --- /dev/null +++ b/research/chatwoot/.gitignore @@ -0,0 +1,106 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep +*.mmdb + +# Ignore Byebug command history file. +.byebug_history +.DS_Store +*.log +# Ignore application configuration +node_modules +master.key +*.rdb + +# Ignore env files +.env + +public/uploads +public/packs* +public/assets/administrate* +public/assets/action*.js +public/assets/activestorage*.js +public/assets/trix* +public/assets/belongs_to*.js +public/assets/manifest*.js +public/assets/manifest*.js +public/assets/*.js.gz +public/assets/secretField* +public/assets/.sprockets-manifest-*.json + +# VIM files +*.swp +*.swo +*.un~ +.jest-cache + +# ignore jetbrains IDE files +.idea + +# coverage report +buildreports +coverage + +/storage + +# ignore packages +node_modules +package-lock.json + +*.dump + + +# cypress +test/cypress/videos/* + +/config/master.key +/config/*.enc + + +# yalc for local testing +.yalc +yalc.lock + +/public/packs +/public/packs-test +/node_modules +/yarn-error.log +yarn-debug.log* +.yarn-integrity + +# Vite Ruby +/public/vite* +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + + +# TextEditors & AI Agents config files +.vscode +.claude/settings.local.json +.cursor +.codex/ +.claude/ +CLAUDE.local.md + +# Histoire deployment +.netlify +.histoire +.pnpm-store/* +local/ +Procfile.worktree diff --git a/research/chatwoot/.husky/pre-commit b/research/chatwoot/.husky/pre-commit new file mode 100755 index 0000000..b3aceac --- /dev/null +++ b/research/chatwoot/.husky/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# lint js and vue files +npx --no-install lint-staged + +# lint only staged ruby files that still exist (not deleted) +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true + +# stage rubocop changes to files +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true diff --git a/research/chatwoot/.husky/pre-push b/research/chatwoot/.husky/pre-push new file mode 100755 index 0000000..f0e139a --- /dev/null +++ b/research/chatwoot/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +sh bin/validate_push diff --git a/research/chatwoot/.nvmrc b/research/chatwoot/.nvmrc new file mode 100644 index 0000000..cf2efde --- /dev/null +++ b/research/chatwoot/.nvmrc @@ -0,0 +1 @@ +24.13.0 \ No newline at end of file diff --git a/research/chatwoot/.prettierrc b/research/chatwoot/.prettierrc new file mode 100644 index 0000000..366415e --- /dev/null +++ b/research/chatwoot/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 80, + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid" +} diff --git a/research/chatwoot/.qlty/.gitignore b/research/chatwoot/.qlty/.gitignore new file mode 100644 index 0000000..3036618 --- /dev/null +++ b/research/chatwoot/.qlty/.gitignore @@ -0,0 +1,7 @@ +* +!configs +!configs/** +!hooks +!hooks/** +!qlty.toml +!.gitignore diff --git a/research/chatwoot/.qlty/configs/.hadolint.yaml b/research/chatwoot/.qlty/configs/.hadolint.yaml new file mode 100644 index 0000000..8f7e23e --- /dev/null +++ b/research/chatwoot/.qlty/configs/.hadolint.yaml @@ -0,0 +1,2 @@ +ignored: + - DL3008 diff --git a/research/chatwoot/.qlty/configs/.shellcheckrc b/research/chatwoot/.qlty/configs/.shellcheckrc new file mode 100644 index 0000000..6a38d92 --- /dev/null +++ b/research/chatwoot/.qlty/configs/.shellcheckrc @@ -0,0 +1 @@ +source-path=SCRIPTDIR \ No newline at end of file diff --git a/research/chatwoot/.qlty/configs/.yamllint.yaml b/research/chatwoot/.qlty/configs/.yamllint.yaml new file mode 100644 index 0000000..d22fa77 --- /dev/null +++ b/research/chatwoot/.qlty/configs/.yamllint.yaml @@ -0,0 +1,8 @@ +rules: + document-start: disable + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/research/chatwoot/.qlty/qlty.toml b/research/chatwoot/.qlty/qlty.toml new file mode 100644 index 0000000..780b383 --- /dev/null +++ b/research/chatwoot/.qlty/qlty.toml @@ -0,0 +1,84 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[smells.boolean_logic] +threshold = 4 + +[smells.file_complexity] +threshold = 66 +enabled = true + +[smells.return_statements] +threshold = 4 + +[smells.nested_control_flow] +threshold = 4 + +[smells.function_parameters] +threshold = 4 + +[smells.function_complexity] +threshold = 5 + +[smells.duplication] +enabled = true +threshold = 20 + +[[source]] +name = "default" +default = true diff --git a/research/chatwoot/.rspec b/research/chatwoot/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/research/chatwoot/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/research/chatwoot/.rubocop.yml b/research/chatwoot/.rubocop.yml new file mode 100644 index 0000000..d87f08b --- /dev/null +++ b/research/chatwoot/.rubocop.yml @@ -0,0 +1,350 @@ +plugins: + - rubocop-performance + - rubocop-rails + - rubocop-rspec + - rubocop-factory_bot + +require: + - ./rubocop/use_from_email.rb + - ./rubocop/custom_cop_location.rb + - ./rubocop/attachment_download.rb + - ./rubocop/one_class_per_file.rb + +Layout/LineLength: + Max: 150 + +Metrics/ClassLength: + Max: 175 + Exclude: + - 'app/models/message.rb' + - 'app/models/conversation.rb' + +Metrics/MethodLength: + Max: 19 + Exclude: + - 'enterprise/lib/captain/agent.rb' + +RSpec/ExampleLength: + Max: 50 + +Style/Documentation: + Enabled: false + +Style/ExponentialNotation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/SymbolArray: + Enabled: false + +Style/OpenStructUse: + Enabled: false + +Chatwoot/AttachmentDownload: + Enabled: true + Exclude: + - 'spec/**/*' + - 'test/**/*' + +Style/OptionalBooleanParameter: + Exclude: + - 'app/services/email_templates/db_resolver_service.rb' + - 'app/dispatchers/dispatcher.rb' + +Style/GlobalVars: + Exclude: + - 'config/initializers/01_redis.rb' + - 'config/initializers/rack_attack.rb' + - 'lib/redis/alfred.rb' + - 'lib/global_config.rb' + +Style/ClassVars: + Exclude: + - 'app/services/email_templates/db_resolver_service.rb' + +Lint/MissingSuper: + Exclude: + - 'app/drops/base_drop.rb' + +Lint/SymbolConversion: + Enabled: false + +Lint/EmptyBlock: + Exclude: + - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' + +Lint/OrAssignmentToConstant: + Exclude: + - 'lib/redis/config.rb' + +Metrics/BlockLength: + Max: 30 + Exclude: + - spec/**/* + - '**/routes.rb' + - 'config/environments/*' + - db/schema.rb + +Metrics/ModuleLength: + Exclude: + - lib/seeders/message_seeder.rb + - spec/support/slack_stubs.rb + +Rails/HelperInstanceVariable: + Exclude: + - enterprise/app/helpers/captain/chat_helper.rb + - enterprise/app/helpers/captain/chat_response_helper.rb +Rails/ApplicationController: + Exclude: + - 'app/controllers/api/v1/widget/messages_controller.rb' + - 'app/controllers/dashboard_controller.rb' + - 'app/controllers/widget_tests_controller.rb' + - 'app/controllers/widgets_controller.rb' + - 'app/controllers/platform_controller.rb' + - 'app/controllers/public_controller.rb' + - 'app/controllers/survey/responses_controller.rb' + +Rails/FindEach: + Enabled: true + Include: + - 'app/**/*.rb' + +Rails/CompactBlank: + Enabled: false + +Rails/EnvironmentVariableAccess: + Enabled: false + +Rails/TimeZoneAssignment: + Enabled: false + +Rails/RedundantPresenceValidationOnBelongsTo: + Enabled: false + +Rails/InverseOf: + Exclude: + - enterprise/app/models/captain/assistant.rb + +Rails/UniqueValidationWithoutIndex: + Exclude: + - app/models/canned_response.rb + - app/models/telegram_bot.rb + - enterprise/app/models/captain_inbox.rb + - 'app/models/channel/twitter_profile.rb' + - 'app/models/webhook.rb' + - 'app/models/contact.rb' + +Style/ClassAndModuleChildren: + EnforcedStyle: compact + Exclude: + - 'config/application.rb' + - 'config/initializers/monkey_patches/*' + +Style/MapToHash: + Enabled: false + +Style/HashSyntax: + Enabled: true + EnforcedStyle: no_mixed_keys + EnforcedShorthandSyntax: never + +RSpec/NestedGroups: + Enabled: true + Max: 4 + +RSpec/MessageSpies: + Enabled: false + +RSpec/StubbedMock: + Enabled: false + +Naming/VariableNumber: + Enabled: false + +Naming/MemoizedInstanceVariableName: + Exclude: + - 'app/models/message.rb' + +Style/GuardClause: + Exclude: + - 'app/builders/account_builder.rb' + - 'app/models/attachment.rb' + - 'app/models/message.rb' + +Metrics/AbcSize: + Max: 26 + Exclude: + - 'app/controllers/concerns/auth_helper.rb' + + - 'app/models/integrations/hook.rb' + - 'app/models/canned_response.rb' + - 'app/models/telegram_bot.rb' + +Rails/RenderInline: + Exclude: + - 'app/controllers/swagger_controller.rb' + +Rails/ThreeStateBooleanColumn: + Exclude: + - 'db/migrate/20230503101201_create_sla_policies.rb' + +RSpec/IndexedLet: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +# we should bring this down +RSpec/MultipleExpectations: + Max: 7 + +RSpec/MultipleMemoizedHelpers: + Max: 14 + +# custom rules +UseFromEmail: + Enabled: true + Exclude: + - 'app/models/user.rb' + - 'app/models/contact.rb' + +CustomCopLocation: + Enabled: true + +Style/OneClassPerFile: + Enabled: true + +AllCops: + NewCops: enable + Exclude: + - 'bin/**/*' + - 'db/schema.rb' + - 'public/**/*' + - 'config/initializers/bot.rb' + - 'vendor/**/*' + - 'node_modules/**/*' + - 'lib/tasks/auto_annotate_models.rake' + - 'config/environments/**/*' + - 'tmp/**/*' + - 'storage/**/*' + - 'db/migrate/20230426130150_init_schema.rb' + +FactoryBot/SyntaxMethods: + Enabled: false + +# Disable new rules causing errors +Layout/LeadingCommentSpace: + Enabled: false + +Style/ReturnNilInPredicateMethodDefinition: + Enabled: false + +Style/RedundantParentheses: + Enabled: false + +Performance/StringIdentifierArgument: + Enabled: false + +Layout/EmptyLinesAroundExceptionHandlingKeywords: + Enabled: false + +Lint/LiteralAsCondition: + Enabled: false + +Style/RedundantReturn: + Enabled: false + +Layout/SpaceAroundOperators: + Enabled: false + +Rails/EnvLocal: + Enabled: false + +Rails/WhereRange: + Enabled: false + +Lint/UselessConstantScoping: + Enabled: false + +Style/MultipleComparison: + Enabled: false + +Bundler/OrderedGems: + Enabled: false + +RSpec/ExampleWording: + Enabled: false + +RSpec/ReceiveMessages: + Enabled: false + +FactoryBot/AssociationStyle: + Enabled: false + +Rails/EnumSyntax: + Enabled: false + +Lint/RedundantTypeConversion: + Enabled: false + +# Additional rules to disable +Rails/RedundantActiveRecordAllMethod: + Enabled: false + +Layout/TrailingEmptyLines: + Enabled: true + +Style/SafeNavigationChainLength: + Enabled: false + +Lint/SafeNavigationConsistency: + Enabled: false + +Lint/CopDirectiveSyntax: + Enabled: false + +# Final set of rules to disable +FactoryBot/ExcessiveCreateList: + Enabled: false + +RSpec/MissingExpectationTargetMethod: + Enabled: false + +Performance/InefficientHashSearch: + Enabled: false + +Style/RedundantSelfAssignmentBranch: + Enabled: false + +Style/YAMLFileRead: + Enabled: false + +Layout/ExtraSpacing: + Enabled: false + +Style/RedundantFilterChain: + Enabled: false + +Performance/MapMethodChain: + Enabled: false + +Rails/RootPathnameMethods: + Enabled: false + +Style/SuperArguments: + Enabled: false + +# Final remaining rules to disable +Rails/Delegate: + Enabled: false + +Style/CaseLikeIf: + Enabled: false + +FactoryBot/RedundantFactoryOption: + Enabled: false + +FactoryBot/FactoryAssociationWithStrategy: + Enabled: false diff --git a/research/chatwoot/.ruby-version b/research/chatwoot/.ruby-version new file mode 100644 index 0000000..f989260 --- /dev/null +++ b/research/chatwoot/.ruby-version @@ -0,0 +1 @@ +3.4.4 diff --git a/research/chatwoot/.scss-lint.yml b/research/chatwoot/.scss-lint.yml new file mode 100644 index 0000000..2477dff --- /dev/null +++ b/research/chatwoot/.scss-lint.yml @@ -0,0 +1,286 @@ +# Default application configuration that all configurations inherit from. + +scss_files: '**/*.scss' +plugin_directories: ['.scss-linters'] + +# List of gem names to load custom linters from (make sure they are already +# installed) +plugin_gems: [] + +# Default severity of all linters. +severity: warning + +linters: + BangFormat: + enabled: true + space_before_bang: true + space_after_bang: false + + BemDepth: + enabled: false + max_elements: 1 + + BorderZero: + enabled: true + convention: zero # or `none` + + ChainedClasses: + enabled: false + + ColorKeyword: + enabled: true + + ColorVariable: + enabled: true + + Comment: + enabled: true + style: silent + + DebugStatement: + enabled: true + + DeclarationOrder: + enabled: true + + DisableLinterReason: + enabled: false + + DuplicateProperty: + enabled: true + + ElsePlacement: + enabled: true + style: new_line + + EmptyLineBetweenBlocks: + enabled: true + ignore_single_line_blocks: true + + EmptyRule: + enabled: true + + ExtendDirective: + enabled: false + + FinalNewline: + enabled: true + present: true + + HexLength: + enabled: true + style: short # or 'long' + + HexNotation: + enabled: true + style: lowercase # or 'uppercase' + + HexValidation: + enabled: true + + IdSelector: + enabled: true + + ImportantRule: + enabled: false + + ImportPath: + enabled: true + leading_underscore: false + filename_extension: false + + Indentation: + enabled: true + allow_non_nested_indentation: false + character: space # or 'tab' + width: 2 + + LeadingZero: + enabled: false + + MergeableSelector: + enabled: true + force_nesting: true + + NameFormat: + enabled: true + allow_leading_underscore: true + convention: hyphenated_lowercase # or 'camel_case', or 'snake_case', or a regex pattern + + NestingDepth: + enabled: true + max_depth: 6 + ignore_parent_selectors: false + + PlaceholderInExtend: + enabled: true + + PrivateNamingConvention: + enabled: false + prefix: _ + + PropertyCount: + enabled: false + include_nested: false + max_properties: 10 + + PropertySortOrder: + enabled: true + ignore_unspecified: false + min_properties: 2 + separate_groups: false + + PropertySpelling: + enabled: true + extra_properties: [] + disabled_properties: [] + + PropertyUnits: + enabled: true + global: [ + 'ch', + 'em', + 'ex', + 'rem', # Font-relative lengths + 'cm', + 'in', + 'mm', + 'pc', + 'pt', + 'px', + 'q', # Absolute lengths + 'vh', + 'vw', + 'vmin', + 'vmax', # Viewport-percentage lengths + 'fr', # Grid fractional lengths + 'deg', + 'grad', + 'rad', + 'turn', # Angle + 'ms', + 's', # Duration + 'Hz', + 'kHz', # Frequency + 'dpi', + 'dpcm', + 'dppx', # Resolution + '%', + ] # Other + properties: {} + + PseudoElement: + enabled: true + + QualifyingElement: + enabled: true + allow_element_with_attribute: false + allow_element_with_class: false + allow_element_with_id: false + exclude: + - 'app/assets/stylesheets/administrate/components/_buttons.scss' + + SelectorDepth: + enabled: true + max_depth: 5 + + SelectorFormat: + enabled: false + + Shorthand: + enabled: true + allowed_shorthands: [1, 2, 3, 4] + + SingleLinePerProperty: + enabled: true + allow_single_line_rule_sets: true + + SingleLinePerSelector: + enabled: true + + SpaceAfterComma: + enabled: true + style: one_space # or 'no_space', or 'at_least_one_space' + + SpaceAfterComment: + enabled: false + style: one_space # or 'no_space', or 'at_least_one_space' + allow_empty_comments: true + + SpaceAfterPropertyColon: + enabled: true + style: one_space # or 'no_space', or 'at_least_one_space', or 'aligned' + + SpaceAfterPropertyName: + enabled: true + + SpaceAfterVariableColon: + enabled: false + style: one_space # or 'no_space', 'at_least_one_space' or 'one_space_or_newline' + + SpaceAfterVariableName: + enabled: true + + SpaceAroundOperator: + enabled: true + style: one_space # or 'at_least_one_space', or 'no_space' + + SpaceBeforeBrace: + enabled: true + style: space # or 'new_line' + allow_single_line_padding: false + + SpaceBetweenParens: + enabled: true + spaces: 0 + + StringQuotes: + enabled: true + style: single_quotes # or double_quotes + + TrailingSemicolon: + enabled: true + + TrailingWhitespace: + enabled: true + + TrailingZero: + enabled: false + + TransitionAll: + enabled: false + + UnnecessaryMantissa: + enabled: false + + UnnecessaryParentReference: + enabled: false + + UrlFormat: + enabled: true + + UrlQuotes: + enabled: true + + VariableForProperty: + enabled: false + properties: [] + + VendorPrefix: + enabled: true + identifier_list: base + additional_identifiers: [] + excluded_identifiers: [] + + ZeroUnit: + enabled: true + + Compass::*: + enabled: false + +exclude: + - 'app/javascript/widget/assets/scss/_reset.scss' + - 'app/javascript/widget/assets/scss/sdk.css' + - 'app/assets/stylesheets/administrate/reset/_normalize.scss' + - 'app/javascript/shared/assets/stylesheets/*.scss' + - 'app/javascript/dashboard/assets/scss/_woot.scss' diff --git a/research/chatwoot/.slugignore b/research/chatwoot/.slugignore new file mode 100644 index 0000000..eaecc7e --- /dev/null +++ b/research/chatwoot/.slugignore @@ -0,0 +1 @@ +/spec diff --git a/research/chatwoot/.windsurf/rules/chatwoot.md b/research/chatwoot/.windsurf/rules/chatwoot.md new file mode 120000 index 0000000..b7e6491 --- /dev/null +++ b/research/chatwoot/.windsurf/rules/chatwoot.md @@ -0,0 +1 @@ +../../AGENTS.md \ No newline at end of file diff --git a/research/chatwoot/AGENTS.md b/research/chatwoot/AGENTS.md new file mode 100644 index 0000000..301633d --- /dev/null +++ b/research/chatwoot/AGENTS.md @@ -0,0 +1,104 @@ +# Chatwoot Development Guidelines + +## Build / Test / Lint + +- **Setup**: `bundle install && pnpm install` +- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification) +- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios) +- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too: + - UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`). + - CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find())"` (or call `Seeders::AccountSeeder.new(account: Account.find()).perform!` directly). +- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` +- **Lint Ruby**: `bundle exec rubocop -a` +- **Test JS**: `pnpm test` or `pnpm test:watch` +- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` +- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` +- **Run Project**: `overmind start -f Procfile.dev` +- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) +- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) + +## Code Style + +- **Ruby**: Follow RuboCop rules (150 character max line length) +- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended) +- **Vue Components**: Use PascalCase +- **Events**: Use camelCase +- **I18n**: No bare strings in templates; use i18n +- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`) +- **Models**: Validate presence/uniqueness, add proper indexes +- **Type Safety**: Use PropTypes in Vue, strong params in Rails +- **Naming**: Use clear, descriptive names with consistent casing +- **Vue API**: Always use Composition API with ` + + + + diff --git a/research/chatwoot/app/javascript/dashboard/api/ApiClient.js b/research/chatwoot/app/javascript/dashboard/api/ApiClient.js new file mode 100644 index 0000000..6b57d32 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/ApiClient.js @@ -0,0 +1,63 @@ +/* global axios */ + +const DEFAULT_API_VERSION = 'v1'; + +class ApiClient { + constructor(resource, options = {}) { + this.apiVersion = `/api/${options.apiVersion || DEFAULT_API_VERSION}`; + this.options = options; + this.resource = resource; + } + + get url() { + return `${this.baseUrl()}/${this.resource}`; + } + + // eslint-disable-next-line class-methods-use-this + get accountIdFromRoute() { + const isInsideAccountScopedURLs = + window.location.pathname.includes('/app/accounts'); + + if (isInsideAccountScopedURLs) { + return window.location.pathname.split('/')[3]; + } + + return ''; + } + + baseUrl() { + let url = this.apiVersion; + + if (this.options.enterprise) { + url = `/enterprise${url}`; + } + + if (this.options.accountScoped && this.accountIdFromRoute) { + url = `${url}/accounts/${this.accountIdFromRoute}`; + } + + return url; + } + + get() { + return axios.get(this.url); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data) { + return axios.post(this.url, data); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default ApiClient; diff --git a/research/chatwoot/app/javascript/dashboard/api/CacheEnabledApiClient.js b/research/chatwoot/app/javascript/dashboard/api/CacheEnabledApiClient.js new file mode 100644 index 0000000..9af939c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/CacheEnabledApiClient.js @@ -0,0 +1,97 @@ +/* global axios */ +import { DataManager } from '../helper/CacheHelper/DataManager'; +import ApiClient from './ApiClient'; + +class CacheEnabledApiClient extends ApiClient { + constructor(resource, options = {}) { + super(resource, options); + this.dataManager = new DataManager(this.accountIdFromRoute); + } + + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + throw new Error('cacheModelName is not defined'); + } + + get(cache = false) { + if (cache) { + return this.getFromCache(); + } + + return this.getFromNetwork(); + } + + getFromNetwork() { + return axios.get(this.url); + } + + // eslint-disable-next-line class-methods-use-this + extractDataFromResponse(response) { + return response.data.payload; + } + + // eslint-disable-next-line class-methods-use-this + marshallData(dataToParse) { + return { data: { payload: dataToParse } }; + } + + async getFromCache() { + try { + // IDB is not supported in Firefox private mode: https://bugzilla.mozilla.org/show_bug.cgi?id=781982 + await this.dataManager.initDb(); + } catch { + return this.getFromNetwork(); + } + + const { data } = await axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` + ); + const cacheKeyFromApi = data.cache_keys[this.cacheModelName]; + const isCacheValid = await this.validateCacheKey(cacheKeyFromApi); + + let localData = []; + if (isCacheValid) { + localData = await this.dataManager.get({ + modelName: this.cacheModelName, + }); + } + + if (localData.length === 0) { + return this.refetchAndCommit(cacheKeyFromApi); + } + + return this.marshallData(localData); + } + + async refetchAndCommit(newKey = null) { + const response = await this.getFromNetwork(); + + try { + await this.dataManager.initDb(); + + this.dataManager.replace({ + modelName: this.cacheModelName, + data: this.extractDataFromResponse(response), + }); + + await this.dataManager.setCacheKeys({ + [this.cacheModelName]: newKey, + }); + } catch { + // Ignore error + } + + return response; + } + + async validateCacheKey(cacheKeyFromApi) { + if (!this.dataManager.db) { + await this.dataManager.initDb(); + } + + const cachekey = await this.dataManager.getCacheKey(this.cacheModelName); + return cacheKeyFromApi === cachekey; + } +} + +export default CacheEnabledApiClient; diff --git a/research/chatwoot/app/javascript/dashboard/api/account.js b/research/chatwoot/app/javascript/dashboard/api/account.js new file mode 100644 index 0000000..c0dcf05 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/account.js @@ -0,0 +1,21 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AccountAPI extends ApiClient { + constructor() { + super('', { accountScoped: true }); + } + + createAccount(data) { + return axios.post(`${this.apiVersion}/accounts`, data); + } + + async getCacheKeys() { + const response = await axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/cache_keys` + ); + return response.data.cache_keys; + } +} + +export default new AccountAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/accountActions.js b/research/chatwoot/app/javascript/dashboard/api/accountActions.js new file mode 100644 index 0000000..d8c46fe --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/accountActions.js @@ -0,0 +1,18 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AccountActions extends ApiClient { + constructor() { + super('actions', { accountScoped: true }); + } + + merge(parentId, childId) { + return axios.post(`${this.url}/contact_merge`, { + base_contact_id: parentId, + mergee_contact_id: childId, + }); + } +} + +export default new AccountActions(); diff --git a/research/chatwoot/app/javascript/dashboard/api/agentBots.js b/research/chatwoot/app/javascript/dashboard/api/agentBots.js new file mode 100644 index 0000000..de887f4 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/agentBots.js @@ -0,0 +1,30 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AgentBotsAPI extends ApiClient { + constructor() { + super('agent_bots', { accountScoped: true }); + } + + create(data) { + return axios.post(this.url, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}`, data, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + deleteAgentBotAvatar(botId) { + return axios.delete(`${this.url}/${botId}/avatar`); + } + + resetAccessToken(botId) { + return axios.post(`${this.url}/${botId}/reset_access_token`); + } +} + +export default new AgentBotsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/agentCapacityPolicies.js b/research/chatwoot/app/javascript/dashboard/api/agentCapacityPolicies.js new file mode 100644 index 0000000..7792ce4 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/agentCapacityPolicies.js @@ -0,0 +1,43 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AgentCapacityPolicies extends ApiClient { + constructor() { + super('agent_capacity_policies', { accountScoped: true }); + } + + getUsers(policyId) { + return axios.get(`${this.url}/${policyId}/users`); + } + + addUser(policyId, userData) { + return axios.post(`${this.url}/${policyId}/users`, { + user_id: userData.id, + capacity: userData.capacity, + }); + } + + removeUser(policyId, userId) { + return axios.delete(`${this.url}/${policyId}/users/${userId}`); + } + + createInboxLimit(policyId, limitData) { + return axios.post(`${this.url}/${policyId}/inbox_limits`, { + inbox_id: limitData.inboxId, + conversation_limit: limitData.conversationLimit, + }); + } + + updateInboxLimit(policyId, limitId, limitData) { + return axios.put(`${this.url}/${policyId}/inbox_limits/${limitId}`, { + conversation_limit: limitData.conversationLimit, + }); + } + + deleteInboxLimit(policyId, limitId) { + return axios.delete(`${this.url}/${policyId}/inbox_limits/${limitId}`); + } +} + +export default new AgentCapacityPolicies(); diff --git a/research/chatwoot/app/javascript/dashboard/api/agents.js b/research/chatwoot/app/javascript/dashboard/api/agents.js new file mode 100644 index 0000000..cfc6b36 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/agents.js @@ -0,0 +1,17 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class Agents extends ApiClient { + constructor() { + super('agents', { accountScoped: true }); + } + + bulkInvite({ emails }) { + return axios.post(`${this.url}/bulk_create`, { + emails, + }); + } +} + +export default new Agents(); diff --git a/research/chatwoot/app/javascript/dashboard/api/assignableAgents.js b/research/chatwoot/app/javascript/dashboard/api/assignableAgents.js new file mode 100644 index 0000000..5b999fa --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/assignableAgents.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AssignableAgents extends ApiClient { + constructor() { + super('assignable_agents', { accountScoped: true }); + } + + get(inboxIds) { + return axios.get(this.url, { + params: { inbox_ids: inboxIds }, + }); + } +} + +export default new AssignableAgents(); diff --git a/research/chatwoot/app/javascript/dashboard/api/assignmentPolicies.js b/research/chatwoot/app/javascript/dashboard/api/assignmentPolicies.js new file mode 100644 index 0000000..e6baca9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/assignmentPolicies.js @@ -0,0 +1,36 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AssignmentPolicies extends ApiClient { + constructor() { + super('assignment_policies', { accountScoped: true }); + } + + getInboxes(policyId) { + return axios.get(`${this.url}/${policyId}/inboxes`); + } + + setInboxPolicy(inboxId, policyId) { + return axios.post( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, + { + assignment_policy_id: policyId, + } + ); + } + + getInboxPolicy(inboxId) { + return axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } + + removeInboxPolicy(inboxId) { + return axios.delete( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } +} + +export default new AssignmentPolicies(); diff --git a/research/chatwoot/app/javascript/dashboard/api/attributes.js b/research/chatwoot/app/javascript/dashboard/api/attributes.js new file mode 100644 index 0000000..3552bb9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/attributes.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AttributeAPI extends ApiClient { + constructor() { + super('custom_attribute_definitions', { accountScoped: true }); + } + + getAttributesByModel() { + return axios.get(this.url); + } +} + +export default new AttributeAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/auditLogs.js b/research/chatwoot/app/javascript/dashboard/api/auditLogs.js new file mode 100644 index 0000000..f3e5864 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/auditLogs.js @@ -0,0 +1,16 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AuditLogs extends ApiClient { + constructor() { + super('audit_logs', { accountScoped: true }); + } + + get({ page }) { + const url = page ? `${this.url}?page=${page}` : this.url; + return axios.get(url); + } +} + +export default new AuditLogs(); diff --git a/research/chatwoot/app/javascript/dashboard/api/auth.js b/research/chatwoot/app/javascript/dashboard/api/auth.js new file mode 100644 index 0000000..a1b15ee --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/auth.js @@ -0,0 +1,109 @@ +/* global axios */ + +import Cookies from 'js-cookie'; +import endPoints from './endPoints'; +import { + clearCookiesOnLogout, + deleteIndexedDBOnLogout, +} from '../store/utils/api'; + +export default { + validityCheck() { + const urlData = endPoints('validityCheck'); + return axios.get(urlData.url); + }, + logout() { + const urlData = endPoints('logout'); + const fetchPromise = new Promise((resolve, reject) => { + axios + .delete(urlData.url) + .then(response => { + deleteIndexedDBOnLogout(); + clearCookiesOnLogout(); + resolve(response); + }) + .catch(error => { + reject(error); + }); + }); + return fetchPromise; + }, + hasAuthCookie() { + return !!Cookies.get('cw_d_session_info'); + }, + getAuthData() { + if (this.hasAuthCookie()) { + const savedAuthInfo = Cookies.get('cw_d_session_info'); + return JSON.parse(savedAuthInfo || '{}'); + } + return false; + }, + profileUpdate({ displayName, avatar, ...profileAttributes }) { + const formData = new FormData(); + Object.keys(profileAttributes).forEach(key => { + const hasValue = profileAttributes[key] === undefined; + if (!hasValue) { + formData.append(`profile[${key}]`, profileAttributes[key]); + } + }); + formData.append('profile[display_name]', displayName || ''); + if (avatar) { + formData.append('profile[avatar]', avatar); + } + return axios.put(endPoints('profileUpdate').url, formData); + }, + + profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { + current_password: currentPassword, + password, + password_confirmation: passwordConfirmation, + }, + }); + }, + + updateUISettings({ uiSettings }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { ui_settings: uiSettings }, + }); + }, + + updateAvailability(availabilityData) { + return axios.post(endPoints('availabilityUpdate').url, { + profile: { ...availabilityData }, + }); + }, + + updateAutoOffline(accountId, autoOffline = false) { + return axios.post(endPoints('autoOffline').url, { + profile: { account_id: accountId, auto_offline: autoOffline }, + }); + }, + + deleteAvatar() { + return axios.delete(endPoints('deleteAvatar').url); + }, + + resetPassword({ email }) { + const urlData = endPoints('resetPassword'); + return axios.post(urlData.url, { email }); + }, + + setActiveAccount({ accountId }) { + const urlData = endPoints('setActiveAccount'); + return axios.put(urlData.url, { + profile: { + account_id: accountId, + }, + }); + }, + resendConfirmation() { + const urlData = endPoints('resendConfirmation'); + return axios.post(urlData.url); + }, + resetAccessToken() { + const urlData = endPoints('resetAccessToken'); + return axios.post(urlData.url); + }, +}; diff --git a/research/chatwoot/app/javascript/dashboard/api/automation.js b/research/chatwoot/app/javascript/dashboard/api/automation.js new file mode 100644 index 0000000..e83ece3 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/automation.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class AutomationsAPI extends ApiClient { + constructor() { + super('automation_rules', { accountScoped: true }); + } + + clone(automationId) { + return axios.post(`${this.url}/${automationId}/clone`); + } +} + +export default new AutomationsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/bulkActions.js b/research/chatwoot/app/javascript/dashboard/api/bulkActions.js new file mode 100644 index 0000000..a606c56 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class BulkActionsAPI extends ApiClient { + constructor() { + super('bulk_actions', { accountScoped: true }); + } +} + +export default new BulkActionsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/campaigns.js b/research/chatwoot/app/javascript/dashboard/api/campaigns.js new file mode 100644 index 0000000..1c73a33 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/campaigns.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class CampaignsAPI extends ApiClient { + constructor() { + super('campaigns', { accountScoped: true }); + } +} + +export default new CampaignsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/cannedResponse.js b/research/chatwoot/app/javascript/dashboard/api/cannedResponse.js new file mode 100644 index 0000000..f558dca --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/cannedResponse.js @@ -0,0 +1,16 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class CannedResponse extends ApiClient { + constructor() { + super('canned_responses', { accountScoped: true }); + } + + get({ searchKey }) { + const url = searchKey ? `${this.url}?search=${searchKey}` : this.url; + return axios.get(url); + } +} + +export default new CannedResponse(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/assistant.js b/research/chatwoot/app/javascript/dashboard/api/captain/assistant.js new file mode 100644 index 0000000..157eba7 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/assistant.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainAssistant extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + }, + }); + } + + playground({ assistantId, messageContent, messageHistory }) { + return axios.post(`${this.url}/${assistantId}/playground`, { + message_content: messageContent, + message_history: messageHistory, + }); + } +} + +export default new CaptainAssistant(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/bulkActions.js b/research/chatwoot/app/javascript/dashboard/api/captain/bulkActions.js new file mode 100644 index 0000000..fd69a11 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/bulkActions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainBulkActionsAPI extends ApiClient { + constructor() { + super('captain/bulk_actions', { accountScoped: true }); + } +} + +export default new CaptainBulkActionsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/copilotMessages.js b/research/chatwoot/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 0000000..49e0539 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/copilotThreads.js b/research/chatwoot/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 0000000..7fdce3b --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/customTools.js b/research/chatwoot/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 0000000..d0818d9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default new CaptainCustomTools(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/document.js b/research/chatwoot/app/javascript/dashboard/api/captain/document.js new file mode 100644 index 0000000..dc22b0c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/document.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainDocument extends ApiClient { + constructor() { + super('captain/documents', { accountScoped: true }); + } + + get({ page = 1, searchKey, assistantId } = {}) { + return axios.get(this.url, { + params: { + page, + searchKey, + assistant_id: assistantId, + }, + }); + } +} + +export default new CaptainDocument(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/inboxes.js b/research/chatwoot/app/javascript/dashboard/api/captain/inboxes.js new file mode 100644 index 0000000..e0a1efd --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/inboxes.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainInboxes extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId } = {}) { + return axios.get(`${this.url}/${assistantId}/inboxes`); + } + + create(params = {}) { + const { assistantId, inboxId } = params; + return axios.post(`${this.url}/${assistantId}/inboxes`, { + inbox: { inbox_id: inboxId }, + }); + } + + delete(params = {}) { + const { assistantId, inboxId } = params; + return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`); + } +} + +export default new CaptainInboxes(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/preferences.js b/research/chatwoot/app/javascript/dashboard/api/captain/preferences.js new file mode 100644 index 0000000..f1ce305 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/preferences.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainPreferences extends ApiClient { + constructor() { + super('captain/preferences', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + updatePreferences(data) { + return axios.put(this.url, data); + } +} + +export default new CaptainPreferences(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/response.js b/research/chatwoot/app/javascript/dashboard/api/captain/response.js new file mode 100644 index 0000000..d48bd81 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/response.js @@ -0,0 +1,22 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainResponses extends ApiClient { + constructor() { + super('captain/assistant_responses', { accountScoped: true }); + } + + get({ page = 1, search, assistantId, documentId, status } = {}) { + return axios.get(this.url, { + params: { + page, + search, + assistant_id: assistantId, + document_id: documentId, + status, + }, + }); + } +} + +export default new CaptainResponses(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/scenarios.js b/research/chatwoot/app/javascript/dashboard/api/captain/scenarios.js new file mode 100644 index 0000000..3e61c28 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/scenarios.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainScenarios extends ApiClient { + constructor() { + super('captain/assistants', { accountScoped: true }); + } + + get({ assistantId, page = 1, searchKey } = {}) { + return axios.get(`${this.url}/${assistantId}/scenarios`, { + params: { page, searchKey }, + }); + } + + show({ assistantId, id }) { + return axios.get(`${this.url}/${assistantId}/scenarios/${id}`); + } + + create({ assistantId, ...data } = {}) { + return axios.post(`${this.url}/${assistantId}/scenarios`, { + scenario: data, + }); + } + + update({ assistantId, id }, data = {}) { + return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, { + scenario: data, + }); + } + + delete({ assistantId, id }) { + return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`); + } +} + +export default new CaptainScenarios(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/tasks.js b/research/chatwoot/app/javascript/dashboard/api/captain/tasks.js new file mode 100644 index 0000000..1b5a383 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/tasks.js @@ -0,0 +1,107 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +/** + * A client for the Captain Tasks API. + * @extends ApiClient + */ +class TasksAPI extends ApiClient { + /** + * Creates a new TasksAPI instance. + */ + constructor() { + super('captain/tasks', { accountScoped: true }); + } + + /** + * Rewrites content with a specific operation. + * @param {Object} options - The rewrite options. + * @param {string} options.content - The content to rewrite. + * @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc). + * @param {string} [options.conversationId] - The conversation ID for context (required for 'improve'). + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the rewritten content. + */ + rewrite({ content, operation, conversationId }, signal) { + return axios.post( + `${this.url}/rewrite`, + { + content, + operation, + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Summarizes a conversation. + * @param {string} conversationId - The conversation ID to summarize. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the summary. + */ + summarize(conversationId, signal) { + return axios.post( + `${this.url}/summarize`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets a reply suggestion for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the reply suggestion. + */ + replySuggestion(conversationId, signal) { + return axios.post( + `${this.url}/reply_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Gets label suggestions for a conversation. + * @param {string} conversationId - The conversation ID. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with label suggestions. + */ + labelSuggestion(conversationId, signal) { + return axios.post( + `${this.url}/label_suggestion`, + { + conversation_display_id: conversationId, + }, + { signal } + ); + } + + /** + * Sends a follow-up message to continue refining a previous task result. + * @param {Object} options - The follow-up options. + * @param {Object} options.followUpContext - The follow-up context from a previous task. + * @param {string} options.message - The follow-up message/request from the user. + * @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context. + */ + followUp({ followUpContext, message, conversationId }, signal) { + return axios.post( + `${this.url}/follow_up`, + { + follow_up_context: followUpContext, + message, + conversation_display_id: conversationId, + }, + { signal } + ); + } +} + +export default new TasksAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/captain/tools.js b/research/chatwoot/app/javascript/dashboard/api/captain/tools.js new file mode 100644 index 0000000..20edaa9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/captain/tools.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainTools extends ApiClient { + constructor() { + super('captain/assistants/tools', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { + params, + }); + } +} + +export default new CaptainTools(); diff --git a/research/chatwoot/app/javascript/dashboard/api/changelog.js b/research/chatwoot/app/javascript/dashboard/api/changelog.js new file mode 100644 index 0000000..8cf0cde --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/changelog.js @@ -0,0 +1,16 @@ +import axios from 'axios'; +import ApiClient from './ApiClient'; +import { CHANGELOG_API_URL } from 'shared/constants/links'; + +class ChangelogApi extends ApiClient { + constructor() { + super('changelog', { apiVersion: 'v1' }); + } + + // eslint-disable-next-line class-methods-use-this + fetchFromHub() { + return axios.get(CHANGELOG_API_URL); + } +} + +export default new ChangelogApi(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js b/research/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js new file mode 100644 index 0000000..f9c9371 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/fbChannel.js @@ -0,0 +1,24 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class FBChannel extends ApiClient { + constructor() { + super('facebook_indicators', { accountScoped: true }); + } + + create(params) { + return axios.post( + `${this.url.replace(this.resource, '')}callbacks/register_facebook_page`, + params + ); + } + + reauthorizeFacebookPage({ omniauthToken, inboxId }) { + return axios.post(`${this.baseUrl()}/callbacks/reauthorize_page`, { + omniauth_token: omniauthToken, + inbox_id: inboxId, + }); + } +} + +export default new FBChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/googleClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/googleClient.js new file mode 100644 index 0000000..8b59919 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/googleClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class MicrosoftClient extends ApiClient { + constructor() { + super('google', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new MicrosoftClient(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/instagramClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 0000000..51ae264 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/microsoftClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/microsoftClient.js new file mode 100644 index 0000000..2d43fb1 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/microsoftClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class MicrosoftClient extends ApiClient { + constructor() { + super('microsoft', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new MicrosoftClient(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/tiktokClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 0000000..389eb26 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/twilioChannel.js b/research/chatwoot/app/javascript/dashboard/api/channel/twilioChannel.js new file mode 100644 index 0000000..a688a1f --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/twilioChannel.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class TwilioChannel extends ApiClient { + constructor() { + super('channels/twilio_channel', { accountScoped: true }); + } +} + +export default new TwilioChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/twitterClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/twitterClient.js new file mode 100644 index 0000000..110cf6c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/twitterClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TwitterClient extends ApiClient { + constructor() { + super('twitter', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new TwitterClient(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js new file mode 100644 index 0000000..67f74a1 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -0,0 +1,95 @@ +import { Device } from '@twilio/voice-sdk'; +import VoiceAPI from './voiceAPIClient'; + +const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected'); + +class TwilioVoiceClient extends EventTarget { + constructor() { + super(); + this.device = null; + this.activeConnection = null; + this.initialized = false; + this.inboxId = null; + } + + async initializeDevice(inboxId) { + this.destroyDevice(); + + const response = await VoiceAPI.getToken(inboxId); + const { token, account_id } = response || {}; + if (!token) throw new Error('Invalid token'); + + this.device = new Device(token, { + allowIncomingWhileBusy: true, + disableAudioContextSounds: true, + appParams: { account_id }, + }); + + this.device.removeAllListeners(); + this.device.on('connect', conn => { + this.activeConnection = conn; + conn.on('disconnect', this.onDisconnect); + }); + + this.device.on('disconnect', this.onDisconnect); + + this.device.on('tokenWillExpire', async () => { + const r = await VoiceAPI.getToken(this.inboxId); + if (r?.token) this.device.updateToken(r.token); + }); + + this.initialized = true; + this.inboxId = inboxId; + + return this.device; + } + + get hasActiveConnection() { + return !!this.activeConnection; + } + + endClientCall() { + if (this.activeConnection) { + this.activeConnection.disconnect(); + } + this.activeConnection = null; + if (this.device) { + this.device.disconnectAll(); + } + } + + destroyDevice() { + if (this.device) { + this.device.destroy(); + } + this.activeConnection = null; + this.device = null; + this.initialized = false; + this.inboxId = null; + } + + async joinClientCall({ to, conversationId }) { + if (!this.device || !this.initialized || !to) return null; + if (this.activeConnection) return this.activeConnection; + + const params = { + To: to, + is_agent: 'true', + conversation_id: conversationId, + }; + + const connection = await this.device.connect({ params }); + this.activeConnection = connection; + + connection.on('disconnect', this.onDisconnect); + + return connection; + } + + onDisconnect = () => { + this.activeConnection = null; + this.dispatchEvent(createCallDisconnectedEvent()); + }; +} + +export default new TwilioVoiceClient(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/research/chatwoot/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js new file mode 100644 index 0000000..6e1e548 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -0,0 +1,40 @@ +/* global axios */ +import ApiClient from '../../ApiClient'; +import ContactsAPI from '../../contacts'; + +class VoiceAPI extends ApiClient { + constructor() { + super('voice', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + initiateCall(contactId, inboxId) { + return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); + } + + leaveConference(inboxId, conversationId) { + return axios + .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + params: { conversation_id: conversationId }, + }) + .then(r => r.data); + } + + joinConference({ conversationId, inboxId, callSid }) { + return axios + .post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { + conversation_id: conversationId, + call_sid: callSid, + }) + .then(r => r.data); + } + + getToken(inboxId) { + if (!inboxId) return Promise.reject(new Error('Inbox ID is required')); + return axios + .get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`) + .then(r => r.data); + } +} + +export default new VoiceAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/webChannel.js b/research/chatwoot/app/javascript/dashboard/api/channel/webChannel.js new file mode 100644 index 0000000..81a1454 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/webChannel.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class WebChannel extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } +} + +export default new WebChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js b/research/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js new file mode 100644 index 0000000..8f51f48 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channel/whatsappChannel.js @@ -0,0 +1,21 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class WhatsappChannel extends ApiClient { + constructor() { + super('whatsapp', { accountScoped: true }); + } + + createEmbeddedSignup(params) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params); + } + + reauthorizeWhatsApp({ inboxId, ...params }) { + return axios.post(`${this.baseUrl()}/whatsapp/authorization`, { + ...params, + inbox_id: inboxId, + }); + } +} + +export default new WhatsappChannel(); diff --git a/research/chatwoot/app/javascript/dashboard/api/channels.js b/research/chatwoot/app/javascript/dashboard/api/channels.js new file mode 100644 index 0000000..25998b1 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/channels.js @@ -0,0 +1,13 @@ +/* eslint no-console: 0 */ +/* global axios */ +/* eslint no-undef: "error" */ +/* eslint no-unused-expressions: ["error", { "allowShortCircuit": true }] */ +import endPoints from './endPoints'; + +export default { + fetchFacebookPages(token, accountId) { + const urlData = endPoints('fetchFacebookPages'); + urlData.params.omniauth_token = token; + return axios.post(urlData.url(accountId), urlData.params); + }, +}; diff --git a/research/chatwoot/app/javascript/dashboard/api/companies.js b/research/chatwoot/app/javascript/dashboard/api/companies.js new file mode 100644 index 0000000..090b530 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/companies.js @@ -0,0 +1,37 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildCompanyParams = (page, sort) => { + let params = `page=${page}`; + if (sort) { + params = `${params}&sort=${sort}`; + } + return params; +}; + +export const buildSearchParams = (query, page, sort) => { + let params = `q=${encodeURIComponent(query)}&page=${page}`; + if (sort) { + params = `${params}&sort=${sort}`; + } + return params; +}; + +class CompanyAPI extends ApiClient { + constructor() { + super('companies', { accountScoped: true }); + } + + get(params = {}) { + const { page = 1, sort = 'name' } = params; + const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`; + return axios.get(requestURL); + } + + search(query = '', page = 1, sort = 'name') { + const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`; + return axios.get(requestURL); + } +} + +export default new CompanyAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/contactNotes.js b/research/chatwoot/app/javascript/dashboard/api/contactNotes.js new file mode 100644 index 0000000..5be3378 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/contactNotes.js @@ -0,0 +1,29 @@ +import ApiClient from './ApiClient'; + +class ContactNotes extends ApiClient { + constructor() { + super('notes', { accountScoped: true }); + this.contactId = null; + } + + get url() { + return `${this.baseUrl()}/contacts/${this.contactId}/notes`; + } + + get(contactId) { + this.contactId = contactId; + return super.get(); + } + + create(contactId, content) { + this.contactId = contactId; + return super.create({ content }); + } + + delete(contactId, id) { + this.contactId = contactId; + return super.delete(id); + } +} + +export default new ContactNotes(); diff --git a/research/chatwoot/app/javascript/dashboard/api/contacts.js b/research/chatwoot/app/javascript/dashboard/api/contacts.js new file mode 100644 index 0000000..1e76ac9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/contacts.js @@ -0,0 +1,104 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildContactParams = (page, sortAttr, label, search) => { + let params = `include_contact_inboxes=false&page=${page}&sort=${sortAttr}`; + if (search) { + params = `${params}&q=${search}`; + } + if (label) { + params = `${params}&labels[]=${label}`; + } + return params; +}; + +class ContactAPI extends ApiClient { + constructor() { + super('contacts', { accountScoped: true }); + } + + get(page, sortAttr = 'name', label = '') { + let requestURL = `${this.url}?${buildContactParams( + page, + sortAttr, + label, + '' + )}`; + return axios.get(requestURL); + } + + show(id) { + return axios.get(`${this.url}/${id}?include_contact_inboxes=false`); + } + + update(id, data) { + return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data); + } + + getConversations(contactId) { + return axios.get(`${this.url}/${contactId}/conversations`); + } + + getContactableInboxes(contactId) { + return axios.get(`${this.url}/${contactId}/contactable_inboxes`); + } + + getContactLabels(contactId) { + return axios.get(`${this.url}/${contactId}/labels`); + } + + initiateCall(contactId, inboxId) { + return axios.post(`${this.url}/${contactId}/call`, { + inbox_id: inboxId, + }); + } + + updateContactLabels(contactId, labels) { + return axios.post(`${this.url}/${contactId}/labels`, { labels }); + } + + search(search = '', page = 1, sortAttr = 'name', label = '') { + let requestURL = `${this.url}/search?${buildContactParams( + page, + sortAttr, + label, + search + )}`; + return axios.get(requestURL); + } + + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; + return axios.get(requestURL); + } + + // eslint-disable-next-line default-param-last + filter(page = 1, sortAttr = 'name', queryPayload) { + let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; + return axios.post(requestURL, queryPayload); + } + + importContacts(file) { + const formData = new FormData(); + formData.append('import_file', file); + return axios.post(`${this.url}/import`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + destroyCustomAttributes(contactId, customAttributes) { + return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, { + custom_attributes: customAttributes, + }); + } + + destroyAvatar(contactId) { + return axios.delete(`${this.url}/${contactId}/avatar`); + } + + exportContacts(queryPayload) { + return axios.post(`${this.url}/export`, queryPayload); + } +} + +export default new ContactAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/conversations.js b/research/chatwoot/app/javascript/dashboard/api/conversations.js new file mode 100644 index 0000000..8761036 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/conversations.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class ConversationApi extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + getLabels(conversationID) { + return axios.get(`${this.url}/${conversationID}/labels`); + } + + updateLabels(conversationID, labels) { + return axios.post(`${this.url}/${conversationID}/labels`, { labels }); + } +} + +export default new ConversationApi(); diff --git a/research/chatwoot/app/javascript/dashboard/api/csatReports.js b/research/chatwoot/app/javascript/dashboard/api/csatReports.js new file mode 100644 index 0000000..2d3ce12 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/csatReports.js @@ -0,0 +1,46 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CSATReportsAPI extends ApiClient { + constructor() { + super('csat_survey_responses', { accountScoped: true }); + } + + get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) { + return axios.get(this.url, { + params: { + page, + since: from, + until: to, + sort: '-created_at', + user_ids, + inbox_id, + team_id, + rating, + }, + }); + } + + download({ from, to, user_ids, inbox_id, team_id, rating } = {}) { + return axios.get(`${this.url}/download`, { + params: { + since: from, + until: to, + sort: '-created_at', + user_ids, + inbox_id, + team_id, + rating, + }, + }); + } + + getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) { + // no ratings for metrics + return axios.get(`${this.url}/metrics`, { + params: { since: from, until: to, user_ids, inbox_id, team_id, rating }, + }); + } +} + +export default new CSATReportsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/customRole.js b/research/chatwoot/app/javascript/dashboard/api/customRole.js new file mode 100644 index 0000000..5074657 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/customRole.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class CustomRole extends ApiClient { + constructor() { + super('custom_roles', { accountScoped: true }); + } +} + +export default new CustomRole(); diff --git a/research/chatwoot/app/javascript/dashboard/api/customViews.js b/research/chatwoot/app/javascript/dashboard/api/customViews.js new file mode 100644 index 0000000..3fd4e12 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/customViews.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CustomViewsAPI extends ApiClient { + constructor() { + super('custom_filters', { accountScoped: true }); + } + + getCustomViewsByFilterType(type) { + return axios.get(`${this.url}?filter_type=${type}`); + } + + deleteCustomViews(id, type) { + return axios.delete(`${this.url}/${id}?filter_type=${type}`); + } +} + +export default new CustomViewsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/dashboardApps.js b/research/chatwoot/app/javascript/dashboard/api/dashboardApps.js new file mode 100644 index 0000000..b22ab69 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/dashboardApps.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class DashboardAppsAPI extends ApiClient { + constructor() { + super('dashboard_apps', { accountScoped: true }); + } +} + +export default new DashboardAppsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/endPoints.js b/research/chatwoot/app/javascript/dashboard/api/endPoints.js new file mode 100644 index 0000000..ecd3f01 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/endPoints.js @@ -0,0 +1,62 @@ +/* eslint arrow-body-style: ["error", "always"] */ + +const endPoints = { + resetPassword: { + url: 'auth/password', + }, + register: { + url: 'api/v1/accounts.json', + }, + validityCheck: { + url: '/auth/validate_token', + }, + profileUpdate: { + url: '/api/v1/profile', + }, + availabilityUpdate: { + url: '/api/v1/profile/availability', + }, + autoOffline: { + url: '/api/v1/profile/auto_offline', + }, + logout: { + url: 'auth/sign_out', + }, + + me: { + url: 'api/v1/conversations.json', + params: { type: 0, page: 1 }, + }, + + getInbox: { + url: 'api/v1/conversations.json', + params: { inbox_id: null }, + }, + + fetchFacebookPages: { + url(accountId) { + return `api/v1/accounts/${accountId}/callbacks/facebook_pages.json`; + }, + params: { omniauth_token: '' }, + }, + + deleteAvatar: { + url: '/api/v1/profile/avatar', + }, + + setActiveAccount: { + url: '/api/v1/profile/set_active_account', + }, + + resendConfirmation: { + url: '/api/v1/profile/resend_confirmation', + }, + + resetAccessToken: { + url: '/api/v1/profile/reset_access_token', + }, +}; + +export default page => { + return endPoints[page]; +}; diff --git a/research/chatwoot/app/javascript/dashboard/api/enterprise/account.js b/research/chatwoot/app/javascript/dashboard/api/enterprise/account.js new file mode 100644 index 0000000..9e6d40a --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/enterprise/account.js @@ -0,0 +1,32 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class EnterpriseAccountAPI extends ApiClient { + constructor() { + super('', { accountScoped: true, enterprise: true }); + } + + checkout() { + return axios.post(`${this.url}checkout`); + } + + subscription() { + return axios.post(`${this.url}subscription`); + } + + getLimits() { + return axios.get(`${this.url}limits`); + } + + toggleDeletion(action) { + return axios.post(`${this.url}toggle_deletion`, { + action_type: action, + }); + } + + createTopupCheckout(credits) { + return axios.post(`${this.url}topup_checkout`, { credits }); + } +} + +export default new EnterpriseAccountAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js b/research/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js new file mode 100644 index 0000000..47d2eb2 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/enterprise/specs/account.spec.js @@ -0,0 +1,89 @@ +import accountAPI from '../account'; +import ApiClient from '../../ApiClient'; + +describe('#enterpriseAccountAPI', () => { + it('creates correct instance', () => { + expect(accountAPI).toBeInstanceOf(ApiClient); + expect(accountAPI).toHaveProperty('get'); + expect(accountAPI).toHaveProperty('show'); + expect(accountAPI).toHaveProperty('create'); + expect(accountAPI).toHaveProperty('update'); + expect(accountAPI).toHaveProperty('delete'); + expect(accountAPI).toHaveProperty('checkout'); + expect(accountAPI).toHaveProperty('toggleDeletion'); + expect(accountAPI).toHaveProperty('createTopupCheckout'); + expect(accountAPI).toHaveProperty('getLimits'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#checkout', () => { + accountAPI.checkout(); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/checkout' + ); + }); + + it('#subscription', () => { + accountAPI.subscription(); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/subscription' + ); + }); + + it('#toggleDeletion with delete action', () => { + accountAPI.toggleDeletion('delete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'delete' } + ); + }); + + it('#toggleDeletion with undelete action', () => { + accountAPI.toggleDeletion('undelete'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/toggle_deletion', + { action_type: 'undelete' } + ); + }); + + it('#createTopupCheckout with credits', () => { + accountAPI.createTopupCheckout(1000); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits: 1000 } + ); + }); + + it('#createTopupCheckout with different credit amounts', () => { + const creditAmounts = [1000, 2500, 6000, 12000]; + creditAmounts.forEach(credits => { + accountAPI.createTopupCheckout(credits); + expect(axiosMock.post).toHaveBeenCalledWith( + '/enterprise/api/v1/topup_checkout', + { credits } + ); + }); + }); + + it('#getLimits', () => { + accountAPI.getLimits(); + expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits'); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/helpCenter/articles.js b/research/chatwoot/app/javascript/dashboard/api/helpCenter/articles.js new file mode 100644 index 0000000..727340e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/helpCenter/articles.js @@ -0,0 +1,77 @@ +/* global axios */ + +import PortalsAPI from './portals'; +import { getArticleSearchURL } from 'dashboard/helper/URLHelper.js'; + +class ArticlesAPI extends PortalsAPI { + constructor() { + super('articles', { accountScoped: true }); + } + + getArticles({ + pageNumber, + portalSlug, + locale, + status, + authorId, + categorySlug, + sort, + }) { + const url = getArticleSearchURL({ + pageNumber, + portalSlug, + locale, + status, + authorId, + categorySlug, + sort, + host: this.url, + }); + + return axios.get(url); + } + + searchArticles({ portalSlug, query }) { + const url = getArticleSearchURL({ + portalSlug, + query, + host: this.url, + }); + return axios.get(url); + } + + getArticle({ id, portalSlug }) { + return axios.get(`${this.url}/${portalSlug}/articles/${id}`); + } + + updateArticle({ portalSlug, articleId, articleObj }) { + return axios.patch( + `${this.url}/${portalSlug}/articles/${articleId}`, + articleObj + ); + } + + createArticle({ portalSlug, articleObj }) { + const { content, title, authorId, categoryId, locale } = articleObj; + return axios.post(`${this.url}/${portalSlug}/articles`, { + content, + title, + author_id: authorId, + category_id: categoryId, + locale, + }); + } + + deleteArticle({ articleId, portalSlug }) { + return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`); + } + + reorderArticles({ portalSlug, reorderedGroup, categorySlug }) { + return axios.post(`${this.url}/${portalSlug}/articles/reorder`, { + positions_hash: reorderedGroup, + category_slug: categorySlug, + }); + } +} + +export default new ArticlesAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/helpCenter/categories.js b/research/chatwoot/app/javascript/dashboard/api/helpCenter/categories.js new file mode 100644 index 0000000..0165849 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/helpCenter/categories.js @@ -0,0 +1,30 @@ +/* global axios */ + +import PortalsAPI from './portals'; + +class CategoriesAPI extends PortalsAPI { + constructor() { + super('categories', { accountScoped: true }); + } + + get({ portalSlug, locale }) { + return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`); + } + + create({ portalSlug, categoryObj }) { + return axios.post(`${this.url}/${portalSlug}/categories`, categoryObj); + } + + update({ portalSlug, categoryId, categoryObj }) { + return axios.patch( + `${this.url}/${portalSlug}/categories/${categoryId}`, + categoryObj + ); + } + + delete({ portalSlug, categoryId }) { + return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`); + } +} + +export default new CategoriesAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/helpCenter/portals.js b/research/chatwoot/app/javascript/dashboard/api/helpCenter/portals.js new file mode 100644 index 0000000..d65dcaf --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/helpCenter/portals.js @@ -0,0 +1,34 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class PortalsAPI extends ApiClient { + constructor() { + super('portals', { accountScoped: true }); + } + + getPortal({ portalSlug, locale }) { + return axios.get(`${this.url}/${portalSlug}?locale=${locale}`); + } + + updatePortal({ portalSlug, portalObj }) { + return axios.patch(`${this.url}/${portalSlug}`, portalObj); + } + + deletePortal(portalSlug) { + return axios.delete(`${this.url}/${portalSlug}`); + } + + deleteLogo(portalSlug) { + return axios.delete(`${this.url}/${portalSlug}/logo`); + } + + sendCnameInstructions(portalSlug, email) { + return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email }); + } + + sslStatus(portalSlug) { + return axios.get(`${this.url}/${portalSlug}/ssl_status`); + } +} + +export default PortalsAPI; diff --git a/research/chatwoot/app/javascript/dashboard/api/inbox/conversation.js b/research/chatwoot/app/javascript/dashboard/api/inbox/conversation.js new file mode 100644 index 0000000..f94fca4 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/inbox/conversation.js @@ -0,0 +1,145 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class ConversationApi extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get({ + inboxId, + status, + assigneeType, + page, + labels, + teamId, + conversationType, + sortBy, + updatedWithin, + }) { + return axios.get(this.url, { + params: { + inbox_id: inboxId, + team_id: teamId, + status, + assignee_type: assigneeType, + page, + labels, + conversation_type: conversationType, + sort_by: sortBy, + updated_within: updatedWithin, + }, + }); + } + + filter(payload) { + return axios.post(`${this.url}/filter`, payload.queryData, { + params: { + page: payload.page, + }, + }); + } + + search({ q }) { + return axios.get(`${this.url}/search`, { + params: { + q, + page: 1, + }, + }); + } + + toggleStatus({ conversationId, status, snoozedUntil = null }) { + return axios.post(`${this.url}/${conversationId}/toggle_status`, { + status, + snoozed_until: snoozedUntil, + }); + } + + togglePriority({ conversationId, priority }) { + return axios.post(`${this.url}/${conversationId}/toggle_priority`, { + priority, + }); + } + + assignAgent({ conversationId, agentId }) { + return axios.post(`${this.url}/${conversationId}/assignments`, { + assignee_id: agentId, + }); + } + + assignTeam({ conversationId, teamId }) { + const params = { team_id: teamId }; + return axios.post(`${this.url}/${conversationId}/assignments`, params); + } + + markMessageRead({ id }) { + return axios.post(`${this.url}/${id}/update_last_seen`); + } + + markMessagesUnread({ id }) { + return axios.post(`${this.url}/${id}/unread`); + } + + toggleTyping({ conversationId, status, isPrivate }) { + return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, { + typing_status: status, + is_private: isPrivate, + }); + } + + mute(conversationId) { + return axios.post(`${this.url}/${conversationId}/mute`); + } + + unmute(conversationId) { + return axios.post(`${this.url}/${conversationId}/unmute`); + } + + meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) { + return axios.get(`${this.url}/meta`, { + params: { + inbox_id: inboxId, + status, + assignee_type: assigneeType, + labels, + team_id: teamId, + conversation_type: conversationType, + }, + }); + } + + sendEmailTranscript({ conversationId, email }) { + return axios.post(`${this.url}/${conversationId}/transcript`, { email }); + } + + updateCustomAttributes({ conversationId, customAttributes }) { + return axios.post(`${this.url}/${conversationId}/custom_attributes`, { + custom_attributes: customAttributes, + }); + } + + fetchParticipants(conversationId) { + return axios.get(`${this.url}/${conversationId}/participants`); + } + + updateParticipants({ conversationId, userIds }) { + return axios.patch(`${this.url}/${conversationId}/participants`, { + user_ids: userIds, + }); + } + + getAllAttachments(conversationId) { + return axios.get(`${this.url}/${conversationId}/attachments`); + } + + getInboxAssistant(conversationId) { + return axios.get(`${this.url}/${conversationId}/inbox_assistant`); + } + + delete(conversationId) { + return axios.delete(`${this.url}/${conversationId}`); + } +} + +export default new ConversationApi(); diff --git a/research/chatwoot/app/javascript/dashboard/api/inbox/message.js b/research/chatwoot/app/javascript/dashboard/api/inbox/message.js new file mode 100644 index 0000000..8f294a0 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/inbox/message.js @@ -0,0 +1,113 @@ +/* eslint no-console: 0 */ +/* global axios */ +import ApiClient from '../ApiClient'; + +export const buildCreatePayload = ({ + message, + isPrivate, + contentAttributes, + echoId, + files, + ccEmails = '', + bccEmails = '', + toEmails = '', + templateParams, +}) => { + let payload; + if (files && files.length !== 0) { + payload = new FormData(); + if (message) { + payload.append('content', message); + } + files.forEach(file => { + payload.append('attachments[]', file); + }); + payload.append('private', isPrivate); + payload.append('echo_id', echoId); + payload.append('cc_emails', ccEmails); + payload.append('bcc_emails', bccEmails); + + if (toEmails) { + payload.append('to_emails', toEmails); + } + if (contentAttributes) { + payload.append('content_attributes', JSON.stringify(contentAttributes)); + } + } else { + payload = { + content: message, + private: isPrivate, + echo_id: echoId, + content_attributes: contentAttributes, + cc_emails: ccEmails, + bcc_emails: bccEmails, + to_emails: toEmails, + template_params: templateParams, + }; + } + return payload; +}; + +class MessageApi extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + create({ + conversationId, + message, + private: isPrivate, + contentAttributes, + echo_id: echoId, + files, + ccEmails = '', + bccEmails = '', + toEmails = '', + templateParams, + }) { + return axios({ + method: 'post', + url: `${this.url}/${conversationId}/messages`, + data: buildCreatePayload({ + message, + isPrivate, + contentAttributes, + echoId, + files, + ccEmails, + bccEmails, + toEmails, + templateParams, + }), + }); + } + + delete(conversationID, messageId) { + return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`); + } + + retry(conversationID, messageId) { + return axios.post( + `${this.url}/${conversationID}/messages/${messageId}/retry` + ); + } + + getPreviousMessages({ conversationId, after, before }) { + const params = { before }; + if (after && Number(after) !== Number(before)) { + params.after = after; + } + return axios.get(`${this.url}/${conversationId}/messages`, { params }); + } + + translateMessage(conversationId, messageId, targetLanguage) { + return axios.post( + `${this.url}/${conversationId}/messages/${messageId}/translate`, + { + target_language: targetLanguage, + } + ); + } +} + +export default new MessageApi(); diff --git a/research/chatwoot/app/javascript/dashboard/api/inboxHealth.js b/research/chatwoot/app/javascript/dashboard/api/inboxHealth.js new file mode 100644 index 0000000..181b041 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/inboxHealth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxHealthAPI extends ApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + getHealthStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/health`); + } +} + +export default new InboxHealthAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/inboxMembers.js b/research/chatwoot/app/javascript/dashboard/api/inboxMembers.js new file mode 100644 index 0000000..64f7884 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/inboxMembers.js @@ -0,0 +1,17 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class InboxMembers extends ApiClient { + constructor() { + super('inbox_members', { accountScoped: true }); + } + + update({ inboxId, agentList }) { + return axios.patch(this.url, { + inbox_id: inboxId, + user_ids: agentList, + }); + } +} + +export default new InboxMembers(); diff --git a/research/chatwoot/app/javascript/dashboard/api/inboxes.js b/research/chatwoot/app/javascript/dashboard/api/inboxes.js new file mode 100644 index 0000000..83ba3e9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/inboxes.js @@ -0,0 +1,47 @@ +/* global axios */ +import CacheEnabledApiClient from './CacheEnabledApiClient'; + +class Inboxes extends CacheEnabledApiClient { + constructor() { + super('inboxes', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + return 'inbox'; + } + + getCampaigns(inboxId) { + return axios.get(`${this.url}/${inboxId}/campaigns`); + } + + deleteInboxAvatar(inboxId) { + return axios.delete(`${this.url}/${inboxId}/avatar`); + } + + getAgentBot(inboxId) { + return axios.get(`${this.url}/${inboxId}/agent_bot`); + } + + setAgentBot(inboxId, botId) { + return axios.post(`${this.url}/${inboxId}/set_agent_bot`, { + agent_bot: botId, + }); + } + + syncTemplates(inboxId) { + return axios.post(`${this.url}/${inboxId}/sync_templates`); + } + + createCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template`, { + template, + }); + } + + getCSATTemplateStatus(inboxId) { + return axios.get(`${this.url}/${inboxId}/csat_template`); + } +} + +export default new Inboxes(); diff --git a/research/chatwoot/app/javascript/dashboard/api/integrations.js b/research/chatwoot/app/javascript/dashboard/api/integrations.js new file mode 100644 index 0000000..d4ffcbc --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/integrations.js @@ -0,0 +1,43 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class IntegrationsAPI extends ApiClient { + constructor() { + super('integrations/apps', { accountScoped: true }); + } + + connectSlack(code) { + return axios.post(`${this.baseUrl()}/integrations/slack`, { code }); + } + + updateSlack({ referenceId }) { + return axios.patch(`${this.baseUrl()}/integrations/slack`, { + reference_id: referenceId, + }); + } + + listAllSlackChannels() { + return axios.get(`${this.baseUrl()}/integrations/slack/list_all_channels`); + } + + delete(integrationId) { + return axios.delete(`${this.baseUrl()}/integrations/${integrationId}`); + } + + createHook(hookData) { + return axios.post(`${this.baseUrl()}/integrations/hooks`, hookData); + } + + deleteHook(hookId) { + return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); + } + + connectShopify({ shopDomain }) { + return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, { + shop_domain: shopDomain, + }); + } +} + +export default new IntegrationsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/integrations/dyte.js b/research/chatwoot/app/javascript/dashboard/api/integrations/dyte.js new file mode 100644 index 0000000..e22a589 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/integrations/dyte.js @@ -0,0 +1,23 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class DyteAPI extends ApiClient { + constructor() { + super('integrations/dyte', { accountScoped: true }); + } + + createAMeeting(conversationId) { + return axios.post(`${this.url}/create_a_meeting`, { + conversation_id: conversationId, + }); + } + + addParticipantToMeeting(messageId) { + return axios.post(`${this.url}/add_participant_to_meeting`, { + message_id: messageId, + }); + } +} + +export default new DyteAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/integrations/linear.js b/research/chatwoot/app/javascript/dashboard/api/integrations/linear.js new file mode 100644 index 0000000..bb327b7 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/integrations/linear.js @@ -0,0 +1,49 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class LinearAPI extends ApiClient { + constructor() { + super('integrations/linear', { accountScoped: true }); + } + + getTeams() { + return axios.get(`${this.url}/teams`); + } + + getTeamEntities(teamId) { + return axios.get(`${this.url}/team_entities?team_id=${teamId}`); + } + + createIssue(data) { + return axios.post(`${this.url}/create_issue`, data); + } + + link_issue(conversationId, issueId, title) { + return axios.post(`${this.url}/link_issue`, { + issue_id: issueId, + conversation_id: conversationId, + title: title, + }); + } + + getLinkedIssue(conversationId) { + return axios.get( + `${this.url}/linked_issues?conversation_id=${conversationId}` + ); + } + + unlinkIssue(linkId, issueIdentifier, conversationId) { + return axios.post(`${this.url}/unlink_issue`, { + link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, + }); + } + + searchIssues(query) { + return axios.get(`${this.url}/search_issue?q=${query}`); + } +} + +export default new LinearAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/integrations/shopify.js b/research/chatwoot/app/javascript/dashboard/api/integrations/shopify.js new file mode 100644 index 0000000..0b6ce8e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/integrations/shopify.js @@ -0,0 +1,17 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +class ShopifyAPI extends ApiClient { + constructor() { + super('integrations/shopify', { accountScoped: true }); + } + + getOrders(contactId) { + return axios.get(`${this.url}/orders`, { + params: { contact_id: contactId }, + }); + } +} + +export default new ShopifyAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/labels.js b/research/chatwoot/app/javascript/dashboard/api/labels.js new file mode 100644 index 0000000..2b521b0 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/labels.js @@ -0,0 +1,14 @@ +import CacheEnabledApiClient from './CacheEnabledApiClient'; + +class LabelsAPI extends CacheEnabledApiClient { + constructor() { + super('labels', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + return 'label'; + } +} + +export default new LabelsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/liveReports.js b/research/chatwoot/app/javascript/dashboard/api/liveReports.js new file mode 100644 index 0000000..1435da2 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/liveReports.js @@ -0,0 +1,20 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class LiveReportsAPI extends ApiClient { + constructor() { + super('live_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getConversationMetric(params = {}) { + return axios.get(`${this.url}/conversation_metrics`, { params }); + } + + getGroupedConversations({ groupBy } = { groupBy: 'assignee_id' }) { + return axios.get(`${this.url}/grouped_conversation_metrics`, { + params: { group_by: groupBy }, + }); + } +} + +export default new LiveReportsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/macros.js b/research/chatwoot/app/javascript/dashboard/api/macros.js new file mode 100644 index 0000000..7b123c9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/macros.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class MacrosAPI extends ApiClient { + constructor() { + super('macros', { accountScoped: true }); + } + + executeMacro({ macroId, conversationIds }) { + return axios.post(`${this.url}/${macroId}/execute`, { + conversation_ids: conversationIds, + }); + } +} + +export default new MacrosAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/mfa.js b/research/chatwoot/app/javascript/dashboard/api/mfa.js new file mode 100644 index 0000000..c18bea3 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/mfa.js @@ -0,0 +1,28 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class MfaAPI extends ApiClient { + constructor() { + super('profile/mfa', { accountScoped: false }); + } + + enable() { + return axios.post(`${this.url}`); + } + + verify(otpCode) { + return axios.post(`${this.url}/verify`, { otp_code: otpCode }); + } + + disable(password, otpCode) { + return axios.delete(this.url, { + data: { password, otp_code: otpCode }, + }); + } + + regenerateBackupCodes(otpCode) { + return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode }); + } +} + +export default new MfaAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/notificationSubscription.js b/research/chatwoot/app/javascript/dashboard/api/notificationSubscription.js new file mode 100644 index 0000000..23424e8 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/notificationSubscription.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class NotificationSubscriptions extends ApiClient { + constructor() { + super('notification_subscriptions'); + } +} + +export default new NotificationSubscriptions(); diff --git a/research/chatwoot/app/javascript/dashboard/api/notifications.js b/research/chatwoot/app/javascript/dashboard/api/notifications.js new file mode 100644 index 0000000..6528462 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/notifications.js @@ -0,0 +1,61 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotificationsAPI extends ApiClient { + constructor() { + super('notifications', { accountScoped: true }); + } + + get({ page, status, type, sortOrder }) { + const includesFilter = [status, type].filter(value => !!value); + + return axios.get(this.url, { + params: { + page, + sort_order: sortOrder, + includes: includesFilter, + }, + }); + } + + getNotifications(contactId) { + return axios.get(`${this.url}/${contactId}/notifications`); + } + + getUnreadCount() { + return axios.get(`${this.url}/unread_count`); + } + + read(primaryActorType, primaryActorId) { + return axios.post(`${this.url}/read_all`, { + primary_actor_type: primaryActorType, + primary_actor_id: primaryActorId, + }); + } + + unRead(id) { + return axios.post(`${this.url}/${id}/unread`); + } + + readAll() { + return axios.post(`${this.url}/read_all`); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } + + deleteAll({ type = 'all' }) { + return axios.post(`${this.url}/destroy_all`, { + type, + }); + } + + snooze({ id, snoozedUntil = null }) { + return axios.post(`${this.url}/${id}/snooze`, { + snoozed_until: snoozedUntil, + }); + } +} + +export default new NotificationsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/notion_auth.js b/research/chatwoot/app/javascript/dashboard/api/notion_auth.js new file mode 100644 index 0000000..8a0027f --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/notion_auth.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class NotionOAuthClient extends ApiClient { + constructor() { + super('notion', { accountScoped: true }); + } + + generateAuthorization() { + return axios.post(`${this.url}/authorization`); + } +} + +export default new NotionOAuthClient(); diff --git a/research/chatwoot/app/javascript/dashboard/api/reports.js b/research/chatwoot/app/javascript/dashboard/api/reports.js new file mode 100644 index 0000000..00f040f --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/reports.js @@ -0,0 +1,113 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +const getTimeOffset = () => -new Date().getTimezoneOffset() / 60; + +class ReportsAPI extends ApiClient { + constructor() { + super('reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getReports({ + metric, + from, + to, + type = 'account', + id, + groupBy, + businessHours, + }) { + return axios.get(`${this.url}`, { + params: { + metric, + since: from, + until: to, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + }, + }); + } + + // eslint-disable-next-line default-param-last + getSummary(since, until, type = 'account', id, groupBy, businessHours) { + return axios.get(`${this.url}/summary`, { + params: { + since, + until, + type, + id, + group_by: groupBy, + business_hours: businessHours, + timezone_offset: getTimeOffset(), + }, + }); + } + + getConversationMetric(type = 'account', page = 1) { + return axios.get(`${this.url}/conversations`, { + params: { + type, + page, + }, + }); + } + + getAgentReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/agents`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getConversationsSummaryReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/conversations_summary`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getConversationTrafficCSV({ daysBefore = 6 } = {}) { + return axios.get(`${this.url}/conversation_traffic`, { + params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, + }); + } + + getLabelReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/labels`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getInboxReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/inboxes`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getTeamReports({ from: since, to: until, businessHours }) { + return axios.get(`${this.url}/teams`, { + params: { since, until, business_hours: businessHours }, + }); + } + + getBotMetrics({ from, to } = {}) { + return axios.get(`${this.url}/bot_metrics`, { + params: { since: from, until: to }, + }); + } + + getBotSummary({ from, to, groupBy, businessHours } = {}) { + return axios.get(`${this.url}/bot_summary`, { + params: { + since: from, + until: to, + type: 'account', + group_by: groupBy, + business_hours: businessHours, + }, + }); + } +} + +export default new ReportsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/samlSettings.js b/research/chatwoot/app/javascript/dashboard/api/samlSettings.js new file mode 100644 index 0000000..7c0f5b2 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/samlSettings.js @@ -0,0 +1,26 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SamlSettingsAPI extends ApiClient { + constructor() { + super('saml_settings', { accountScoped: true }); + } + + get() { + return axios.get(this.url); + } + + create(data) { + return axios.post(this.url, { saml_settings: data }); + } + + update(data) { + return axios.put(this.url, { saml_settings: data }); + } + + delete() { + return axios.delete(this.url); + } +} + +export default new SamlSettingsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/search.js b/research/chatwoot/app/javascript/dashboard/api/search.js new file mode 100644 index 0000000..10214f3 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/search.js @@ -0,0 +1,64 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SearchAPI extends ApiClient { + constructor() { + super('search', { accountScoped: true }); + } + + get({ q }) { + return axios.get(this.url, { + params: { + q, + }, + }); + } + + contacts({ q, page = 1, since, until }) { + return axios.get(`${this.url}/contacts`, { + params: { + q, + page: page, + since, + until, + }, + }); + } + + conversations({ q, page = 1, since, until }) { + return axios.get(`${this.url}/conversations`, { + params: { + q, + page: page, + since, + until, + }, + }); + } + + messages({ q, page = 1, since, until, from, inboxId }) { + return axios.get(`${this.url}/messages`, { + params: { + q, + page: page, + since, + until, + from, + inbox_id: inboxId, + }, + }); + } + + articles({ q, page = 1, since, until }) { + return axios.get(`${this.url}/articles`, { + params: { + q, + page: page, + since, + until, + }, + }); + } +} + +export default new SearchAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/sla.js b/research/chatwoot/app/javascript/dashboard/api/sla.js new file mode 100644 index 0000000..8480b18 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/sla.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class SlaAPI extends ApiClient { + constructor() { + super('sla_policies', { accountScoped: true }); + } +} + +export default new SlaAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/slaReports.js b/research/chatwoot/app/javascript/dashboard/api/slaReports.js new file mode 100644 index 0000000..fedc988 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/slaReports.js @@ -0,0 +1,78 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SLAReportsAPI extends ApiClient { + constructor() { + super('applied_slas', { accountScoped: true }); + } + + get({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + } = {}) { + return axios.get(this.url, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + page, + }, + }); + } + + download({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + sla_policy_id, + label_list, + } = {}) { + return axios.get(`${this.url}/download`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + }, + }); + } + + getMetrics({ + from, + to, + assigned_agent_id, + inbox_id, + team_id, + label_list, + sla_policy_id, + } = {}) { + return axios.get(`${this.url}/metrics`, { + params: { + since: from, + until: to, + assigned_agent_id, + inbox_id, + label_list, + team_id, + sla_policy_id, + }, + }); + } +} + +export default new SLAReportsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/account.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/account.spec.js new file mode 100644 index 0000000..4da8b3a --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/account.spec.js @@ -0,0 +1,41 @@ +import accountAPI from '../account'; +import ApiClient from '../ApiClient'; + +describe('#accountAPI', () => { + it('creates correct instance', () => { + expect(accountAPI).toBeInstanceOf(ApiClient); + expect(accountAPI).toHaveProperty('get'); + expect(accountAPI).toHaveProperty('show'); + expect(accountAPI).toHaveProperty('create'); + expect(accountAPI).toHaveProperty('update'); + expect(accountAPI).toHaveProperty('delete'); + expect(accountAPI).toHaveProperty('createAccount'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#createAccount', () => { + accountAPI.createAccount({ + name: 'Chatwoot', + }); + expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/accounts', { + name: 'Chatwoot', + }); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/accountActions.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/accountActions.spec.js new file mode 100644 index 0000000..dc73e49 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/accountActions.spec.js @@ -0,0 +1,38 @@ +import accountActionsAPI from '../accountActions'; +import ApiClient from '../ApiClient'; + +describe('#ContactsAPI', () => { + it('creates correct instance', () => { + expect(accountActionsAPI).toBeInstanceOf(ApiClient); + expect(accountActionsAPI).toHaveProperty('merge'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#merge', () => { + accountActionsAPI.merge(1, 2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/actions/contact_merge', + { + base_contact_id: 1, + mergee_contact_id: 2, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/agentBots.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/agentBots.spec.js new file mode 100644 index 0000000..bf57804 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/agentBots.spec.js @@ -0,0 +1,14 @@ +import AgentBotsAPI from '../agentBots'; +import ApiClient from '../ApiClient'; + +describe('#AgentBotsAPI', () => { + it('creates correct instance', () => { + expect(AgentBotsAPI).toBeInstanceOf(ApiClient); + expect(AgentBotsAPI).toHaveProperty('get'); + expect(AgentBotsAPI).toHaveProperty('show'); + expect(AgentBotsAPI).toHaveProperty('create'); + expect(AgentBotsAPI).toHaveProperty('update'); + expect(AgentBotsAPI).toHaveProperty('delete'); + expect(AgentBotsAPI).toHaveProperty('resetAccessToken'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js new file mode 100644 index 0000000..43932aa --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/agentCapacityPolicies.spec.js @@ -0,0 +1,98 @@ +import agentCapacityPolicies from '../agentCapacityPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AgentCapacityPoliciesAPI', () => { + it('creates correct instance', () => { + expect(agentCapacityPolicies).toBeInstanceOf(ApiClient); + expect(agentCapacityPolicies).toHaveProperty('get'); + expect(agentCapacityPolicies).toHaveProperty('show'); + expect(agentCapacityPolicies).toHaveProperty('create'); + expect(agentCapacityPolicies).toHaveProperty('update'); + expect(agentCapacityPolicies).toHaveProperty('delete'); + expect(agentCapacityPolicies).toHaveProperty('getUsers'); + expect(agentCapacityPolicies).toHaveProperty('addUser'); + expect(agentCapacityPolicies).toHaveProperty('removeUser'); + expect(agentCapacityPolicies).toHaveProperty('createInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('updateInboxLimit'); + expect(agentCapacityPolicies).toHaveProperty('deleteInboxLimit'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + put: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(agentCapacityPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getUsers', () => { + agentCapacityPolicies.getUsers(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users' + ); + }); + + it('#addUser', () => { + const userData = { id: 456, capacity: 20 }; + agentCapacityPolicies.addUser(123, userData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users', + { + user_id: 456, + capacity: 20, + } + ); + }); + + it('#removeUser', () => { + agentCapacityPolicies.removeUser(123, 456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/users/456' + ); + }); + + it('#createInboxLimit', () => { + const limitData = { inboxId: 1, conversationLimit: 10 }; + agentCapacityPolicies.createInboxLimit(123, limitData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits', + { + inbox_id: 1, + conversation_limit: 10, + } + ); + }); + + it('#updateInboxLimit', () => { + const limitData = { conversationLimit: 15 }; + agentCapacityPolicies.updateInboxLimit(123, 789, limitData); + expect(axiosMock.put).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789', + { + conversation_limit: 15, + } + ); + }); + + it('#deleteInboxLimit', () => { + agentCapacityPolicies.deleteInboxLimit(123, 789); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/agent_capacity_policies/123/inbox_limits/789' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/agents.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/agents.spec.js new file mode 100644 index 0000000..0df0fd8 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/agents.spec.js @@ -0,0 +1,38 @@ +import agents from '../agents'; +import ApiClient from '../ApiClient'; + +describe('#AgentAPI', () => { + it('creates correct instance', () => { + expect(agents).toBeInstanceOf(ApiClient); + expect(agents).toHaveProperty('get'); + expect(agents).toHaveProperty('show'); + expect(agents).toHaveProperty('create'); + expect(agents).toHaveProperty('update'); + expect(agents).toHaveProperty('delete'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#bulkInvite', () => { + agents.bulkInvite({ emails: ['hello@hi.com'] }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/agents/bulk_create', + { + emails: ['hello@hi.com'], + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/article.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/article.spec.js new file mode 100644 index 0000000..7112868 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/article.spec.js @@ -0,0 +1,156 @@ +import articlesAPI from '../helpCenter/articles'; +import ApiClient from 'dashboard/api/helpCenter/portals'; + +describe('#PortalAPI', () => { + it('creates correct instance', () => { + expect(articlesAPI).toBeInstanceOf(ApiClient); + expect(articlesAPI).toHaveProperty('get'); + expect(articlesAPI).toHaveProperty('show'); + expect(articlesAPI).toHaveProperty('create'); + expect(articlesAPI).toHaveProperty('update'); + expect(articlesAPI).toHaveProperty('delete'); + expect(articlesAPI).toHaveProperty('getArticles'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getArticles', () => { + articlesAPI.getArticles({ + pageNumber: 1, + portalSlug: 'room-rental', + locale: 'en-US', + status: 'published', + authorId: '1', + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1' + ); + }); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getArticle', () => { + articlesAPI.getArticle({ + id: 1, + portalSlug: 'room-rental', + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/1' + ); + }); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#searchArticles', () => { + articlesAPI.searchArticles({ + query: 'test', + portalSlug: 'room-rental', + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles?query=test' + ); + }); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#updateArticle', () => { + articlesAPI.updateArticle({ + articleId: 1, + portalSlug: 'room-rental', + articleObj: { title: 'Update shipping address' }, + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/1', + { + title: 'Update shipping address', + } + ); + }); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#deleteArticle', () => { + articlesAPI.deleteArticle({ + articleId: 1, + portalSlug: 'room-rental', + }); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/portals/room-rental/articles/1' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/assignableAgents.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/assignableAgents.spec.js new file mode 100644 index 0000000..d553d55 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/assignableAgents.spec.js @@ -0,0 +1,30 @@ +import assignableAgentsAPI from '../assignableAgents'; + +describe('#AssignableAgentsAPI', () => { + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getAssignableAgents', () => { + assignableAgentsAPI.get([1]); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', { + params: { + inbox_ids: [1], + }, + }); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js new file mode 100644 index 0000000..8d0aea7 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js @@ -0,0 +1,70 @@ +import assignmentPolicies from '../assignmentPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AssignmentPoliciesAPI', () => { + it('creates correct instance', () => { + expect(assignmentPolicies).toBeInstanceOf(ApiClient); + expect(assignmentPolicies).toHaveProperty('get'); + expect(assignmentPolicies).toHaveProperty('show'); + expect(assignmentPolicies).toHaveProperty('create'); + expect(assignmentPolicies).toHaveProperty('update'); + expect(assignmentPolicies).toHaveProperty('delete'); + expect(assignmentPolicies).toHaveProperty('getInboxes'); + expect(assignmentPolicies).toHaveProperty('setInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('getInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('removeInboxPolicy'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getInboxes', () => { + assignmentPolicies.getInboxes(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/assignment_policies/123/inboxes' + ); + }); + + it('#setInboxPolicy', () => { + assignmentPolicies.setInboxPolicy(456, 123); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy', + { + assignment_policy_id: 123, + } + ); + }); + + it('#getInboxPolicy', () => { + assignmentPolicies.getInboxPolicy(456); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + + it('#removeInboxPolicy', () => { + assignmentPolicies.removeInboxPolicy(456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/automation.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/automation.spec.js new file mode 100644 index 0000000..2ab4cae --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/automation.spec.js @@ -0,0 +1,15 @@ +import automations from '../automation'; +import ApiClient from '../ApiClient'; + +describe('#AutomationsAPI', () => { + it('creates correct instance', () => { + expect(automations).toBeInstanceOf(ApiClient); + expect(automations).toHaveProperty('get'); + expect(automations).toHaveProperty('show'); + expect(automations).toHaveProperty('create'); + expect(automations).toHaveProperty('update'); + expect(automations).toHaveProperty('delete'); + expect(automations).toHaveProperty('clone'); + expect(automations.url).toBe('/api/v1/automation_rules'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/bulkAction.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/bulkAction.spec.js new file mode 100644 index 0000000..aec0b1e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/bulkAction.spec.js @@ -0,0 +1,9 @@ +import bulkActions from '../bulkActions'; +import ApiClient from '../ApiClient'; + +describe('#BulkActionsAPI', () => { + it('creates correct instance', () => { + expect(bulkActions).toBeInstanceOf(ApiClient); + expect(bulkActions).toHaveProperty('create'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/campaign.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/campaign.spec.js new file mode 100644 index 0000000..b94b6da --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/campaign.spec.js @@ -0,0 +1,13 @@ +import campaigns from '../campaigns'; +import ApiClient from '../ApiClient'; + +describe('#CampaignAPI', () => { + it('creates correct instance', () => { + expect(campaigns).toBeInstanceOf(ApiClient); + expect(campaigns).toHaveProperty('get'); + expect(campaigns).toHaveProperty('show'); + expect(campaigns).toHaveProperty('create'); + expect(campaigns).toHaveProperty('update'); + expect(campaigns).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js new file mode 100644 index 0000000..c790519 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js @@ -0,0 +1,54 @@ +import fbChannel from '../../channel/fbChannel'; +import ApiClient from '../../ApiClient'; + +describe('#FBChannel', () => { + it('creates correct instance', () => { + expect(fbChannel).toBeInstanceOf(ApiClient); + expect(fbChannel).toHaveProperty('get'); + expect(fbChannel).toHaveProperty('show'); + expect(fbChannel).toHaveProperty('create'); + expect(fbChannel).toHaveProperty('update'); + expect(fbChannel).toHaveProperty('delete'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#create', () => { + fbChannel.create({ omniauthToken: 'ASFM131CSF@#@$', appId: 'chatwoot' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/callbacks/register_facebook_page', + { + omniauthToken: 'ASFM131CSF@#@$', + appId: 'chatwoot', + } + ); + }); + it('#reauthorize', () => { + fbChannel.reauthorizeFacebookPage({ + omniauthToken: 'ASFM131CSF@#@$', + inboxId: 1, + }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/callbacks/reauthorize_page', + { + omniauth_token: 'ASFM131CSF@#@$', + inbox_id: 1, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js new file mode 100644 index 0000000..5025de4 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js @@ -0,0 +1,13 @@ +import twilioChannel from '../../channel/twilioChannel'; +import ApiClient from '../../ApiClient'; + +describe('#twilioChannel', () => { + it('creates correct instance', () => { + expect(twilioChannel).toBeInstanceOf(ApiClient); + expect(twilioChannel).toHaveProperty('get'); + expect(twilioChannel).toHaveProperty('show'); + expect(twilioChannel).toHaveProperty('create'); + expect(twilioChannel).toHaveProperty('update'); + expect(twilioChannel).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js new file mode 100644 index 0000000..0768ee5 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js @@ -0,0 +1,14 @@ +import twitterClient from '../../channel/twitterClient'; +import ApiClient from '../../ApiClient'; + +describe('#TwitterClient', () => { + it('creates correct instance', () => { + expect(twitterClient).toBeInstanceOf(ApiClient); + expect(twitterClient).toHaveProperty('get'); + expect(twitterClient).toHaveProperty('show'); + expect(twitterClient).toHaveProperty('create'); + expect(twitterClient).toHaveProperty('update'); + expect(twitterClient).toHaveProperty('delete'); + expect(twitterClient).toHaveProperty('generateAuthorization'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/channel/webChannel.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/channel/webChannel.spec.js new file mode 100644 index 0000000..c14e79e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/channel/webChannel.spec.js @@ -0,0 +1,13 @@ +import webChannelClient from '../../channel/webChannel'; +import ApiClient from '../../ApiClient'; + +describe('#webChannelClient', () => { + it('creates correct instance', () => { + expect(webChannelClient).toBeInstanceOf(ApiClient); + expect(webChannelClient).toHaveProperty('get'); + expect(webChannelClient).toHaveProperty('show'); + expect(webChannelClient).toHaveProperty('create'); + expect(webChannelClient).toHaveProperty('update'); + expect(webChannelClient).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js new file mode 100644 index 0000000..82fdc1c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/companies.spec.js @@ -0,0 +1,142 @@ +import companyAPI, { + buildCompanyParams, + buildSearchParams, +} from '../companies'; +import ApiClient from '../ApiClient'; + +describe('#CompanyAPI', () => { + it('creates correct instance', () => { + expect(companyAPI).toBeInstanceOf(ApiClient); + expect(companyAPI).toHaveProperty('get'); + expect(companyAPI).toHaveProperty('show'); + expect(companyAPI).toHaveProperty('create'); + expect(companyAPI).toHaveProperty('update'); + expect(companyAPI).toHaveProperty('delete'); + expect(companyAPI).toHaveProperty('search'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get with default params', () => { + companyAPI.get({}); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=1&sort=name' + ); + }); + + it('#get with page and sort params', () => { + companyAPI.get({ page: 2, sort: 'domain' }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=2&sort=domain' + ); + }); + + it('#get with descending sort', () => { + companyAPI.get({ page: 1, sort: '-created_at' }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=1&sort=-created_at' + ); + }); + + it('#search with query', () => { + companyAPI.search('acme', 1, 'name'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=acme&page=1&sort=name' + ); + }); + + it('#search with special characters in query', () => { + companyAPI.search('acme & co', 2, 'domain'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain' + ); + }); + + it('#search with descending sort', () => { + companyAPI.search('test', 1, '-created_at'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=test&page=1&sort=-created_at' + ); + }); + + it('#search with empty query', () => { + companyAPI.search('', 1, 'name'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=&page=1&sort=name' + ); + }); + }); +}); + +describe('#buildCompanyParams', () => { + it('returns correct string with page only', () => { + expect(buildCompanyParams(1)).toBe('page=1'); + }); + + it('returns correct string with page and sort', () => { + expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name'); + }); + + it('returns correct string with different page', () => { + expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain'); + }); + + it('returns correct string with descending sort', () => { + expect(buildCompanyParams(1, '-created_at')).toBe( + 'page=1&sort=-created_at' + ); + }); + + it('returns correct string without sort parameter', () => { + expect(buildCompanyParams(2, '')).toBe('page=2'); + }); +}); + +describe('#buildSearchParams', () => { + it('returns correct string with all parameters', () => { + expect(buildSearchParams('acme', 1, 'name')).toBe( + 'q=acme&page=1&sort=name' + ); + }); + + it('returns correct string with special characters', () => { + expect(buildSearchParams('acme & co', 2, 'domain')).toBe( + 'q=acme%20%26%20co&page=2&sort=domain' + ); + }); + + it('returns correct string with empty query', () => { + expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name'); + }); + + it('returns correct string without sort parameter', () => { + expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1'); + }); + + it('returns correct string with descending sort', () => { + expect(buildSearchParams('company', 3, '-created_at')).toBe( + 'q=company&page=3&sort=-created_at' + ); + }); + + it('encodes special characters correctly', () => { + expect(buildSearchParams('test@example.com', 1, 'name')).toBe( + 'q=test%40example.com&page=1&sort=name' + ); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js new file mode 100644 index 0000000..0059518 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/contacts.spec.js @@ -0,0 +1,138 @@ +import contactAPI, { buildContactParams } from '../contacts'; +import ApiClient from '../ApiClient'; + +describe('#ContactsAPI', () => { + it('creates correct instance', () => { + expect(contactAPI).toBeInstanceOf(ApiClient); + expect(contactAPI).toHaveProperty('get'); + expect(contactAPI).toHaveProperty('show'); + expect(contactAPI).toHaveProperty('create'); + expect(contactAPI).toHaveProperty('update'); + expect(contactAPI).toHaveProperty('delete'); + expect(contactAPI).toHaveProperty('getConversations'); + expect(contactAPI).toHaveProperty('filter'); + expect(contactAPI).toHaveProperty('destroyAvatar'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + contactAPI.get(1, 'name', 'customer-support'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts?include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support' + ); + }); + + it('#getConversations', () => { + contactAPI.getConversations(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/1/conversations' + ); + }); + + it('#getContactableInboxes', () => { + contactAPI.getContactableInboxes(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/1/contactable_inboxes' + ); + }); + + it('#getContactLabels', () => { + contactAPI.getContactLabels(1); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/contacts/1/labels'); + }); + + it('#updateContactLabels', () => { + const labels = ['support-query']; + contactAPI.updateContactLabels(1, labels); + expect(axiosMock.post).toHaveBeenCalledWith('/api/v1/contacts/1/labels', { + labels, + }); + }); + + it('#search', () => { + contactAPI.search('leads', 1, 'date', 'customer-support'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' + ); + }); + + it('#destroyCustomAttributes', () => { + contactAPI.destroyCustomAttributes(1, ['cloudCustomer']); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/contacts/1/destroy_custom_attributes', + { + custom_attributes: ['cloudCustomer'], + } + ); + }); + + it('#importContacts', () => { + const file = 'file'; + contactAPI.importContacts(file); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/contacts/import', + expect.any(FormData), + { + headers: { 'Content-Type': 'multipart/form-data' }, + } + ); + }); + + it('#filter', () => { + const queryPayload = { + payload: [ + { + attribute_key: 'email', + filter_operator: 'contains', + values: ['fayaz'], + query_operator: null, + }, + ], + }; + contactAPI.filter(1, 'name', queryPayload); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name', + queryPayload + ); + }); + + it('#destroyAvatar', () => { + contactAPI.destroyAvatar(1); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/contacts/1/avatar' + ); + }); + }); +}); + +describe('#buildContactParams', () => { + it('returns correct string', () => { + expect(buildContactParams(1, 'name', '', '')).toBe( + 'include_contact_inboxes=false&page=1&sort=name' + ); + expect(buildContactParams(1, 'name', 'customer-support', '')).toBe( + 'include_contact_inboxes=false&page=1&sort=name&labels[]=customer-support' + ); + expect( + buildContactParams(1, 'name', 'customer-support', 'message-content') + ).toBe( + 'include_contact_inboxes=false&page=1&sort=name&q=message-content&labels[]=customer-support' + ); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/conversations.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/conversations.spec.js new file mode 100644 index 0000000..7ae4eb7 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/conversations.spec.js @@ -0,0 +1,51 @@ +import conversationsAPI from '../conversations'; +import ApiClient from '../ApiClient'; + +describe('#ConversationApi', () => { + it('creates correct instance', () => { + expect(conversationsAPI).toBeInstanceOf(ApiClient); + expect(conversationsAPI).toHaveProperty('get'); + expect(conversationsAPI).toHaveProperty('show'); + expect(conversationsAPI).toHaveProperty('create'); + expect(conversationsAPI).toHaveProperty('update'); + expect(conversationsAPI).toHaveProperty('delete'); + expect(conversationsAPI).toHaveProperty('getLabels'); + expect(conversationsAPI).toHaveProperty('updateLabels'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getLabels', () => { + conversationsAPI.getLabels(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/1/labels' + ); + }); + + it('#updateLabels', () => { + const labels = ['support-query']; + conversationsAPI.updateLabels(1, labels); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/1/labels', + { + labels, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js new file mode 100644 index 0000000..788f0eb --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/csatReports.spec.js @@ -0,0 +1,70 @@ +import csatReportsAPI from '../csatReports'; +import ApiClient from '../ApiClient'; + +describe('#Reports API', () => { + it('creates correct instance', () => { + expect(csatReportsAPI).toBeInstanceOf(ApiClient); + expect(csatReportsAPI.apiVersion).toBe('/api/v1'); + expect(csatReportsAPI).toHaveProperty('get'); + expect(csatReportsAPI).toHaveProperty('getMetrics'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + csatReportsAPI.get({ page: 1, from: 1622485800, to: 1623695400 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/csat_survey_responses', + { + params: { + page: 1, + since: 1622485800, + until: 1623695400, + sort: '-created_at', + }, + } + ); + }); + it('#getMetrics', () => { + csatReportsAPI.getMetrics({ from: 1622485800, to: 1623695400 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/csat_survey_responses/metrics', + { + params: { since: 1622485800, until: 1623695400 }, + } + ); + }); + it('#download', () => { + csatReportsAPI.download({ + from: 1622485800, + to: 1623695400, + user_ids: 1, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/csat_survey_responses/download', + { + params: { + since: 1622485800, + until: 1623695400, + user_ids: 1, + sort: '-created_at', + }, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/dashboardApps.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/dashboardApps.spec.js new file mode 100644 index 0000000..f3196ae --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/dashboardApps.spec.js @@ -0,0 +1,13 @@ +import dashboardAppsAPI from '../dashboardApps'; +import ApiClient from '../ApiClient'; + +describe('#dashboardAppsAPI', () => { + it('creates correct instance', () => { + expect(dashboardAppsAPI).toBeInstanceOf(ApiClient); + expect(dashboardAppsAPI).toHaveProperty('get'); + expect(dashboardAppsAPI).toHaveProperty('show'); + expect(dashboardAppsAPI).toHaveProperty('create'); + expect(dashboardAppsAPI).toHaveProperty('update'); + expect(dashboardAppsAPI).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js new file mode 100644 index 0000000..8c46735 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/endPoints.spec.js @@ -0,0 +1,13 @@ +import endPoints from '../endPoints'; + +describe('#endPoints', () => { + it('it should return register url details if register page passed ', () => { + expect(endPoints('register')).toEqual({ url: 'api/v1/accounts.json' }); + }); + it('it should inbox url details if getInbox page passed', () => { + expect(endPoints('getInbox')).toEqual({ + url: 'api/v1/conversations.json', + params: { inbox_id: null }, + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js new file mode 100644 index 0000000..2c56f4e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/helpCenter/categories.spec.js @@ -0,0 +1,12 @@ +import categoriesAPI from '../../helpCenter/categories'; +import ApiClient from '../../ApiClient'; + +describe('#BulkActionsAPI', () => { + it('creates correct instance', () => { + expect(categoriesAPI).toBeInstanceOf(ApiClient); + expect(categoriesAPI).toHaveProperty('get'); + expect(categoriesAPI).toHaveProperty('create'); + expect(categoriesAPI).toHaveProperty('update'); + expect(categoriesAPI).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js new file mode 100644 index 0000000..de0d7a7 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -0,0 +1,232 @@ +import conversationAPI from '../../inbox/conversation'; +import ApiClient from '../../ApiClient'; + +describe('#ConversationAPI', () => { + it('creates correct instance', () => { + expect(conversationAPI).toBeInstanceOf(ApiClient); + expect(conversationAPI).toHaveProperty('get'); + expect(conversationAPI).toHaveProperty('show'); + expect(conversationAPI).toHaveProperty('create'); + expect(conversationAPI).toHaveProperty('update'); + expect(conversationAPI).toHaveProperty('delete'); + expect(conversationAPI).toHaveProperty('toggleStatus'); + expect(conversationAPI).toHaveProperty('assignAgent'); + expect(conversationAPI).toHaveProperty('assignTeam'); + expect(conversationAPI).toHaveProperty('markMessageRead'); + expect(conversationAPI).toHaveProperty('toggleTyping'); + expect(conversationAPI).toHaveProperty('mute'); + expect(conversationAPI).toHaveProperty('unmute'); + expect(conversationAPI).toHaveProperty('meta'); + expect(conversationAPI).toHaveProperty('sendEmailTranscript'); + expect(conversationAPI).toHaveProperty('filter'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get conversations', () => { + conversationAPI.get({ + inboxId: 1, + status: 'open', + assigneeType: 'me', + page: 1, + labels: [], + teamId: 1, + updatedWithin: 20, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', { + params: { + inbox_id: 1, + team_id: 1, + status: 'open', + assignee_type: 'me', + page: 1, + labels: [], + updated_within: 20, + }, + }); + }); + + it('#search', () => { + conversationAPI.search({ + q: 'leads', + page: 1, + }); + + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/search', + { + params: { + q: 'leads', + page: 1, + }, + } + ); + }); + + it('#toggleStatus', () => { + conversationAPI.toggleStatus({ conversationId: 12, status: 'online' }); + expect(axiosMock.post).toHaveBeenCalledWith( + `/api/v1/conversations/12/toggle_status`, + { + status: 'online', + snoozed_until: null, + } + ); + }); + + it('#assignAgent', () => { + conversationAPI.assignAgent({ conversationId: 12, agentId: 34 }); + expect(axiosMock.post).toHaveBeenCalledWith( + `/api/v1/conversations/12/assignments`, + { + assignee_id: 34, + } + ); + }); + + it('#assignTeam', () => { + conversationAPI.assignTeam({ conversationId: 12, teamId: 1 }); + expect(axiosMock.post).toHaveBeenCalledWith( + `/api/v1/conversations/12/assignments`, + { + team_id: 1, + } + ); + }); + + it('#markMessageRead', () => { + conversationAPI.markMessageRead({ id: 12 }); + expect(axiosMock.post).toHaveBeenCalledWith( + `/api/v1/conversations/12/update_last_seen` + ); + }); + + it('#toggleTyping', () => { + conversationAPI.toggleTyping({ + conversationId: 12, + status: 'typing_on', + }); + expect(axiosMock.post).toHaveBeenCalledWith( + `/api/v1/conversations/12/toggle_typing_status`, + { + typing_status: 'typing_on', + } + ); + }); + + it('#mute', () => { + conversationAPI.mute(45); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/45/mute' + ); + }); + + it('#unmute', () => { + conversationAPI.unmute(45); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/45/unmute' + ); + }); + + it('#meta', () => { + conversationAPI.meta({ + inboxId: 1, + status: 'open', + assigneeType: 'me', + labels: [], + teamId: 1, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations/meta', { + params: { + inbox_id: 1, + team_id: 1, + status: 'open', + assignee_type: 'me', + labels: [], + }, + }); + }); + + it('#sendEmailTranscript', () => { + conversationAPI.sendEmailTranscript({ + conversationId: 45, + email: 'john@acme.inc', + }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/45/transcript', + { + email: 'john@acme.inc', + } + ); + }); + + it('#updateCustomAttributes', () => { + conversationAPI.updateCustomAttributes({ + conversationId: 45, + customAttributes: { order_d: '1001' }, + }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/45/custom_attributes', + { + custom_attributes: { order_d: '1001' }, + } + ); + }); + + it('#filter', () => { + const payload = { + page: 1, + queryData: { + payload: [ + { + attribute_key: 'status', + filter_operator: 'equal_to', + values: ['pending', 'resolved'], + query_operator: 'and', + }, + { + attribute_key: 'assignee', + filter_operator: 'equal_to', + values: [3], + query_operator: 'and', + }, + { + attribute_key: 'id', + filter_operator: 'equal_to', + values: ['This is a test'], + query_operator: null, + }, + ], + }, + }; + conversationAPI.filter(payload); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/conversations/filter', + payload.queryData, + { params: { page: payload.page } } + ); + }); + + it('#getAllAttachments', () => { + conversationAPI.getAllAttachments(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/1/attachments' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/inbox/message.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/inbox/message.spec.js new file mode 100644 index 0000000..941f5c9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -0,0 +1,87 @@ +import messageAPI, { buildCreatePayload } from '../../inbox/message'; +import ApiClient from '../../ApiClient'; + +describe('#ConversationAPI', () => { + it('creates correct instance', () => { + expect(messageAPI).toBeInstanceOf(ApiClient); + expect(messageAPI).toHaveProperty('get'); + expect(messageAPI).toHaveProperty('show'); + expect(messageAPI).toHaveProperty('create'); + expect(messageAPI).toHaveProperty('update'); + expect(messageAPI).toHaveProperty('delete'); + expect(messageAPI).toHaveProperty('getPreviousMessages'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getPreviousMessages', () => { + messageAPI.getPreviousMessages({ + conversationId: 12, + before: 4573, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + `/api/v1/conversations/12/messages`, + { + params: { + before: 4573, + }, + } + ); + }); + }); + describe('#buildCreatePayload', () => { + it('builds form payload if file is available', () => { + const formPayload = buildCreatePayload({ + message: 'test content', + echoId: 12, + isPrivate: true, + contentAttributes: { in_reply_to: 12 }, + files: [new Blob(['test-content'], { type: 'application/pdf' })], + }); + expect(formPayload).toBeInstanceOf(FormData); + expect(formPayload.get('content')).toEqual('test content'); + expect(formPayload.get('echo_id')).toEqual('12'); + expect(formPayload.get('private')).toEqual('true'); + expect(formPayload.get('cc_emails')).toEqual(''); + expect(formPayload.get('bcc_emails')).toEqual(''); + expect(formPayload.get('content_attributes')).toEqual( + '{"in_reply_to":12}' + ); + }); + + it('builds object payload if file is not available', () => { + expect( + buildCreatePayload({ + message: 'test content', + isPrivate: false, + echoId: 12, + contentAttributes: { in_reply_to: 12 }, + }) + ).toEqual({ + content: 'test content', + private: false, + echo_id: 12, + content_attributes: { in_reply_to: 12 }, + cc_emails: '', + bcc_emails: '', + to_emails: '', + template_params: undefined, + }); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js new file mode 100644 index 0000000..64ba44a --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -0,0 +1,52 @@ +import inboxesAPI from '../inboxes'; +import ApiClient from '../ApiClient'; + +describe('#InboxesAPI', () => { + it('creates correct instance', () => { + expect(inboxesAPI).toBeInstanceOf(ApiClient); + expect(inboxesAPI).toHaveProperty('get'); + expect(inboxesAPI).toHaveProperty('show'); + expect(inboxesAPI).toHaveProperty('create'); + expect(inboxesAPI).toHaveProperty('update'); + expect(inboxesAPI).toHaveProperty('delete'); + expect(inboxesAPI).toHaveProperty('getCampaigns'); + expect(inboxesAPI).toHaveProperty('getAgentBot'); + expect(inboxesAPI).toHaveProperty('setAgentBot'); + expect(inboxesAPI).toHaveProperty('syncTemplates'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getCampaigns', () => { + inboxesAPI.getCampaigns(2); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns'); + }); + + it('#deleteInboxAvatar', () => { + inboxesAPI.deleteInboxAvatar(2); + expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar'); + }); + + it('#syncTemplates', () => { + inboxesAPI.syncTemplates(2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/inboxes/2/sync_templates' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js new file mode 100644 index 0000000..cc20fd8 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/integrations.spec.js @@ -0,0 +1,88 @@ +import integrationAPI from '../integrations'; +import ApiClient from '../ApiClient'; + +describe('#integrationAPI', () => { + it('creates correct instance', () => { + expect(integrationAPI).toBeInstanceOf(ApiClient); + expect(integrationAPI).toHaveProperty('get'); + expect(integrationAPI).toHaveProperty('show'); + expect(integrationAPI).toHaveProperty('create'); + expect(integrationAPI).toHaveProperty('update'); + expect(integrationAPI).toHaveProperty('delete'); + expect(integrationAPI).toHaveProperty('connectSlack'); + expect(integrationAPI).toHaveProperty('updateSlack'); + expect(integrationAPI).toHaveProperty('updateSlack'); + expect(integrationAPI).toHaveProperty('listAllSlackChannels'); + expect(integrationAPI).toHaveProperty('deleteHook'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#connectSlack', () => { + const code = 'SDNFJNSDFNDSJN'; + integrationAPI.connectSlack(code); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/slack', + { + code, + } + ); + }); + + it('#updateSlack', () => { + const updateObj = { referenceId: 'SDFSDGSVE' }; + integrationAPI.updateSlack(updateObj); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/integrations/slack', + { + reference_id: updateObj.referenceId, + } + ); + }); + + it('#listAllSlackChannels', () => { + integrationAPI.listAllSlackChannels(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/slack/list_all_channels' + ); + }); + + it('#delete', () => { + integrationAPI.delete(2); + expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/integrations/2'); + }); + + it('#createHook', () => { + const hookData = { + app_id: 'fullcontact', + settings: { api_key: 'SDFSDGSVE' }, + }; + integrationAPI.createHook(hookData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/hooks', + hookData + ); + }); + + it('#deleteHook', () => { + integrationAPI.deleteHook(2); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/integrations/hooks/2' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/integrations/dyte.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/integrations/dyte.spec.js new file mode 100644 index 0000000..1c544f9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/integrations/dyte.spec.js @@ -0,0 +1,66 @@ +import DyteAPIClient from '../../integrations/dyte'; +import ApiClient from '../../ApiClient'; + +describe('#accountAPI', () => { + it('creates correct instance', () => { + expect(DyteAPIClient).toBeInstanceOf(ApiClient); + expect(DyteAPIClient).toHaveProperty('createAMeeting'); + expect(DyteAPIClient).toHaveProperty('addParticipantToMeeting'); + }); + + describe('createAMeeting', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + DyteAPIClient.createAMeeting(1); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/dyte/create_a_meeting', + { + conversation_id: 1, + } + ); + }); + }); + + describe('addParticipantToMeeting', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + DyteAPIClient.addParticipantToMeeting(1); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/dyte/add_participant_to_meeting', + { + message_id: 1, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js new file mode 100644 index 0000000..3f33e3e --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -0,0 +1,241 @@ +import LinearAPIClient from '../../integrations/linear'; +import ApiClient from '../../ApiClient'; + +describe('#linearAPI', () => { + it('creates correct instance', () => { + expect(LinearAPIClient).toBeInstanceOf(ApiClient); + expect(LinearAPIClient).toHaveProperty('getTeams'); + expect(LinearAPIClient).toHaveProperty('getTeamEntities'); + expect(LinearAPIClient).toHaveProperty('createIssue'); + expect(LinearAPIClient).toHaveProperty('link_issue'); + expect(LinearAPIClient).toHaveProperty('getLinkedIssue'); + expect(LinearAPIClient).toHaveProperty('unlinkIssue'); + expect(LinearAPIClient).toHaveProperty('searchIssues'); + }); + + describe('getTeams', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeams(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/teams' + ); + }); + }); + + describe('getTeamEntities', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getTeamEntities(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/team_entities?team_id=1' + ); + }); + }); + + describe('createIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); + }); + + describe('link_issue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.link_issue(1, 2); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 2, + conversation_id: 1, + } + ); + }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); + }); + + describe('getLinkedIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.getLinkedIssue(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/linked_issues?conversation_id=1' + ); + }); + }); + + describe('unlinkIssue', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, + } + ); + }); + }); + + describe('searchIssues', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('creates a valid request', () => { + LinearAPIClient.searchIssues('query'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/integrations/linear/search_issue?q=query' + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/labels.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/labels.spec.js new file mode 100644 index 0000000..f43c807 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/labels.spec.js @@ -0,0 +1,14 @@ +import labels from '../labels'; +import ApiClient from '../ApiClient'; + +describe('#LabelsAPI', () => { + it('creates correct instance', () => { + expect(labels).toBeInstanceOf(ApiClient); + expect(labels).toHaveProperty('get'); + expect(labels).toHaveProperty('show'); + expect(labels).toHaveProperty('create'); + expect(labels).toHaveProperty('update'); + expect(labels).toHaveProperty('delete'); + expect(labels.url).toBe('/api/v1/labels'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/macros.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/macros.spec.js new file mode 100644 index 0000000..94e9365 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/macros.spec.js @@ -0,0 +1,14 @@ +import macros from '../macros'; +import ApiClient from '../ApiClient'; + +describe('#macrosAPI', () => { + it('creates correct instance', () => { + expect(macros).toBeInstanceOf(ApiClient); + expect(macros).toHaveProperty('get'); + expect(macros).toHaveProperty('create'); + expect(macros).toHaveProperty('update'); + expect(macros).toHaveProperty('delete'); + expect(macros).toHaveProperty('show'); + expect(macros.url).toBe('/api/v1/macros'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js new file mode 100644 index 0000000..770a684 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/notifications.spec.js @@ -0,0 +1,120 @@ +import notificationsAPI from '../notifications'; +import ApiClient from '../ApiClient'; + +describe('#NotificationAPI', () => { + it('creates correct instance', () => { + expect(notificationsAPI).toBeInstanceOf(ApiClient); + expect(notificationsAPI).toHaveProperty('get'); + expect(notificationsAPI).toHaveProperty('getNotifications'); + expect(notificationsAPI).toHaveProperty('getUnreadCount'); + expect(notificationsAPI).toHaveProperty('read'); + expect(notificationsAPI).toHaveProperty('readAll'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + describe('#get', () => { + it('generates the API call if both params are available', () => { + notificationsAPI.get({ + page: 1, + status: 'snoozed', + type: 'read', + sortOrder: 'desc', + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', { + params: { + page: 1, + sort_order: 'desc', + includes: ['snoozed', 'read'], + }, + }); + }); + + it('generates the API call if one of the params are available', () => { + notificationsAPI.get({ + page: 1, + type: 'read', + sortOrder: 'desc', + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/notifications', { + params: { + page: 1, + sort_order: 'desc', + includes: ['read'], + }, + }); + }); + }); + + it('#getNotifications', () => { + notificationsAPI.getNotifications(1); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/notifications/1/notifications' + ); + }); + + it('#getUnreadCount', () => { + notificationsAPI.getUnreadCount(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/notifications/unread_count' + ); + }); + + it('#read', () => { + notificationsAPI.read(48670, 'Conversation'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/read_all', + { + primary_actor_id: 'Conversation', + primary_actor_type: 48670, + } + ); + }); + + it('#readAll', () => { + notificationsAPI.readAll(); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/read_all' + ); + }); + + it('#snooze', () => { + notificationsAPI.snooze({ id: 1, snoozedUntil: 12332211 }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/1/snooze', + { + snoozed_until: 12332211, + } + ); + }); + + it('#delete', () => { + notificationsAPI.delete(1); + expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/notifications/1'); + }); + + it('#deleteAll', () => { + notificationsAPI.deleteAll({ type: 'all' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/notifications/destroy_all', + { + type: 'all', + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/portals.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/portals.spec.js new file mode 100644 index 0000000..8d6968c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/portals.spec.js @@ -0,0 +1,13 @@ +import PortalsAPI from '../helpCenter/portals'; +import ApiClient from '../ApiClient'; +const portalAPI = new PortalsAPI(); +describe('#PortalAPI', () => { + it('creates correct instance', () => { + expect(portalAPI).toBeInstanceOf(ApiClient); + expect(portalAPI).toHaveProperty('get'); + expect(portalAPI).toHaveProperty('show'); + expect(portalAPI).toHaveProperty('create'); + expect(portalAPI).toHaveProperty('update'); + expect(portalAPI).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js new file mode 100644 index 0000000..e458633 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/reports.spec.js @@ -0,0 +1,161 @@ +import reportsAPI from '../reports'; +import ApiClient from '../ApiClient'; + +describe('#Reports API', () => { + it('creates correct instance', () => { + expect(reportsAPI).toBeInstanceOf(ApiClient); + expect(reportsAPI.apiVersion).toBe('/api/v2'); + expect(reportsAPI).toHaveProperty('get'); + expect(reportsAPI).toHaveProperty('show'); + expect(reportsAPI).toHaveProperty('create'); + expect(reportsAPI).toHaveProperty('update'); + expect(reportsAPI).toHaveProperty('delete'); + expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getSummary'); + expect(reportsAPI).toHaveProperty('getAgentReports'); + expect(reportsAPI).toHaveProperty('getLabelReports'); + expect(reportsAPI).toHaveProperty('getInboxReports'); + expect(reportsAPI).toHaveProperty('getTeamReports'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getAccountReports', () => { + reportsAPI.getReports({ + metric: 'conversations_count', + from: 1621103400, + to: 1621621800, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { + params: { + metric: 'conversations_count', + since: 1621103400, + until: 1621621800, + type: 'account', + timezone_offset: -0, + }, + }); + }); + + it('#getAccountSummary', () => { + reportsAPI.getSummary(1621103400, 1621621800); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/summary', { + params: { + business_hours: undefined, + group_by: undefined, + id: undefined, + since: 1621103400, + timezone_offset: -0, + type: 'account', + until: 1621621800, + }, + }); + }); + + it('#getAgentReports', () => { + reportsAPI.getAgentReports({ + from: 1621103400, + to: 1621621800, + businessHours: true, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/agents', { + params: { + since: 1621103400, + until: 1621621800, + business_hours: true, + }, + }); + }); + + it('#getLabelReports', () => { + reportsAPI.getLabelReports({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/labels', { + params: { + since: 1621103400, + until: 1621621800, + }, + }); + }); + + it('#getInboxReports', () => { + reportsAPI.getInboxReports({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/inboxes', { + params: { + since: 1621103400, + until: 1621621800, + }, + }); + }); + + it('#getTeamReports', () => { + reportsAPI.getTeamReports({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/teams', { + params: { + since: 1621103400, + until: 1621621800, + }, + }); + }); + + it('#getBotMetrics', () => { + reportsAPI.getBotMetrics({ from: 1621103400, to: 1621621800 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_metrics', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); + + it('#getBotSummary', () => { + reportsAPI.getBotSummary({ + from: 1621103400, + to: 1621621800, + groupBy: 'date', + businessHours: true, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/bot_summary', + { + params: { + since: 1621103400, + until: 1621621800, + type: 'account', + group_by: 'date', + business_hours: true, + }, + } + ); + }); + + it('#getConversationMetric', () => { + reportsAPI.getConversationMetric('account'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/conversations', + { + params: { + type: 'account', + page: 1, + }, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/search.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/search.spec.js new file mode 100644 index 0000000..251ea76 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/search.spec.js @@ -0,0 +1,134 @@ +import searchAPI from '../search'; +import ApiClient from '../ApiClient'; + +describe('#SearchAPI', () => { + it('creates correct instance', () => { + expect(searchAPI).toBeInstanceOf(ApiClient); + expect(searchAPI).toHaveProperty('get'); + expect(searchAPI).toHaveProperty('contacts'); + expect(searchAPI).toHaveProperty('conversations'); + expect(searchAPI).toHaveProperty('messages'); + expect(searchAPI).toHaveProperty('articles'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + vi.clearAllMocks(); + }); + + it('#get', () => { + searchAPI.get({ q: 'test query' }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', { + params: { q: 'test query' }, + }); + }); + + it('#contacts', () => { + searchAPI.contacts({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#contacts with date filters', () => { + searchAPI.contacts({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + + it('#conversations', () => { + searchAPI.conversations({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + } + ); + }); + + it('#conversations with date filters', () => { + searchAPI.conversations({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/search/conversations', + { + params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 }, + } + ); + }); + + it('#messages', () => { + searchAPI.messages({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: undefined, + until: undefined, + from: undefined, + inbox_id: undefined, + }, + }); + }); + + it('#messages with all filters', () => { + searchAPI.messages({ + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inboxId: 10, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', { + params: { + q: 'test', + page: 1, + since: 1700000000, + until: 1732000000, + from: 'contact:42', + inbox_id: 10, + }, + }); + }); + + it('#articles', () => { + searchAPI.articles({ q: 'test', page: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 1, since: undefined, until: undefined }, + }); + }); + + it('#articles with date filters', () => { + searchAPI.articles({ + q: 'test', + page: 2, + since: 1700000000, + until: 1732000000, + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', { + params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 }, + }); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js new file mode 100644 index 0000000..827b44c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/slaReports.spec.js @@ -0,0 +1,104 @@ +import SLAReportsAPI from '../slaReports'; +import ApiClient from '../ApiClient'; + +describe('#SLAReports API', () => { + it('creates correct instance', () => { + expect(SLAReportsAPI).toBeInstanceOf(ApiClient); + expect(SLAReportsAPI.apiVersion).toBe('/api/v1'); + expect(SLAReportsAPI).toHaveProperty('get'); + expect(SLAReportsAPI).toHaveProperty('getMetrics'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get', () => { + SLAReportsAPI.get({ + page: 1, + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/applied_slas', { + params: { + page: 1, + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + }); + }); + it('#getMetrics', () => { + SLAReportsAPI.getMetrics({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/metrics', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + it('#download', () => { + SLAReportsAPI.download({ + from: 1622485800, + to: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/applied_slas/download', + { + params: { + since: 1622485800, + until: 1623695400, + assigned_agent_id: 1, + inbox_id: 1, + team_id: 1, + sla_policy_id: 1, + label_list: ['label1'], + }, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/teams.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/teams.spec.js new file mode 100644 index 0000000..c7bfc4d --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/teams.spec.js @@ -0,0 +1,64 @@ +import teamsAPI from '../teams'; +import ApiClient from '../ApiClient'; + +describe('#TeamsAPI', () => { + it('creates correct instance', () => { + expect(teamsAPI).toBeInstanceOf(ApiClient); + expect(teamsAPI).toHaveProperty('get'); + expect(teamsAPI).toHaveProperty('show'); + expect(teamsAPI).toHaveProperty('create'); + expect(teamsAPI).toHaveProperty('update'); + expect(teamsAPI).toHaveProperty('delete'); + expect(teamsAPI).toHaveProperty('getAgents'); + expect(teamsAPI).toHaveProperty('addAgents'); + expect(teamsAPI).toHaveProperty('updateAgents'); + }); + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getAgents', () => { + teamsAPI.getAgents({ teamId: 1 }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/teams/1/team_members' + ); + }); + + it('#addAgents', () => { + teamsAPI.addAgents({ teamId: 1, agentsList: { user_ids: [1, 10, 21] } }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/teams/1/team_members', + { + user_ids: { user_ids: [1, 10, 21] }, + } + ); + }); + + it('#updateAgents', () => { + const agentsList = { user_ids: [1, 10, 21] }; + teamsAPI.updateAgents({ + teamId: 1, + agentsList, + }); + expect(axiosMock.patch).toHaveBeenCalledWith( + '/api/v1/teams/1/team_members', + { + user_ids: agentsList, + } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 0000000..5250e2c --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js new file mode 100644 index 0000000..f65f1c3 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/userNotificationSettings.spec.js @@ -0,0 +1,13 @@ +import userNotificationSettings from '../userNotificationSettings'; +import ApiClient from '../ApiClient'; + +describe('#AgentAPI', () => { + it('creates correct instance', () => { + expect(userNotificationSettings).toBeInstanceOf(ApiClient); + expect(userNotificationSettings).toHaveProperty('get'); + expect(userNotificationSettings).toHaveProperty('show'); + expect(userNotificationSettings).toHaveProperty('create'); + expect(userNotificationSettings).toHaveProperty('update'); + expect(userNotificationSettings).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/specs/webhook.spec.js b/research/chatwoot/app/javascript/dashboard/api/specs/webhook.spec.js new file mode 100644 index 0000000..5a1db9d --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/specs/webhook.spec.js @@ -0,0 +1,13 @@ +import webhooksAPI from '../webhooks'; +import ApiClient from '../ApiClient'; + +describe('#webhooksAPI', () => { + it('creates correct instance', () => { + expect(webhooksAPI).toBeInstanceOf(ApiClient); + expect(webhooksAPI).toHaveProperty('get'); + expect(webhooksAPI).toHaveProperty('show'); + expect(webhooksAPI).toHaveProperty('create'); + expect(webhooksAPI).toHaveProperty('update'); + expect(webhooksAPI).toHaveProperty('delete'); + }); +}); diff --git a/research/chatwoot/app/javascript/dashboard/api/summaryReports.js b/research/chatwoot/app/javascript/dashboard/api/summaryReports.js new file mode 100644 index 0000000..fad26bf --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/summaryReports.js @@ -0,0 +1,50 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class SummaryReportsAPI extends ApiClient { + constructor() { + super('summary_reports', { accountScoped: true, apiVersion: 'v2' }); + } + + getTeamReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/team`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getAgentReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/agent`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getInboxReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/inbox`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } +} + +export default new SummaryReportsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/teams.js b/research/chatwoot/app/javascript/dashboard/api/teams.js new file mode 100644 index 0000000..5413af9 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/teams.js @@ -0,0 +1,42 @@ +/* global axios */ +// import ApiClient from './ApiClient'; +import CacheEnabledApiClient from './CacheEnabledApiClient'; + +export class TeamsAPI extends CacheEnabledApiClient { + constructor() { + super('teams', { accountScoped: true }); + } + + // eslint-disable-next-line class-methods-use-this + get cacheModelName() { + return 'team'; + } + + // eslint-disable-next-line class-methods-use-this + extractDataFromResponse(response) { + return response.data; + } + + // eslint-disable-next-line class-methods-use-this + marshallData(dataToParse) { + return { data: dataToParse }; + } + + getAgents({ teamId }) { + return axios.get(`${this.url}/${teamId}/team_members`); + } + + addAgents({ teamId, agentsList }) { + return axios.post(`${this.url}/${teamId}/team_members`, { + user_ids: agentsList, + }); + } + + updateAgents({ teamId, agentsList }) { + return axios.patch(`${this.url}/${teamId}/team_members`, { + user_ids: agentsList, + }); + } +} + +export default new TeamsAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/api/userNotificationSettings.js b/research/chatwoot/app/javascript/dashboard/api/userNotificationSettings.js new file mode 100644 index 0000000..33829a6 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/userNotificationSettings.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class UserNotificationSettings extends ApiClient { + constructor() { + super('notification_settings', { accountScoped: true }); + } + + update(params) { + return axios.patch(`${this.url}`, params); + } +} + +export default new UserNotificationSettings(); diff --git a/research/chatwoot/app/javascript/dashboard/api/webhooks.js b/research/chatwoot/app/javascript/dashboard/api/webhooks.js new file mode 100644 index 0000000..1e03f25 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/webhooks.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class WebHooks extends ApiClient { + constructor() { + super('webhooks', { accountScoped: true }); + } +} + +export default new WebHooks(); diff --git a/research/chatwoot/app/javascript/dashboard/api/yearInReview.js b/research/chatwoot/app/javascript/dashboard/api/yearInReview.js new file mode 100644 index 0000000..fb06618 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/api/yearInReview.js @@ -0,0 +1,16 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class YearInReviewAPI extends ApiClient { + constructor() { + super('year_in_review', { accountScoped: true, apiVersion: 'v2' }); + } + + get(year) { + return axios.get(`${this.url}`, { + params: { year }, + }); + } +} + +export default new YearInReviewAPI(); diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/Mask.png b/research/chatwoot/app/javascript/dashboard/assets/images/Mask.png new file mode 100644 index 0000000..1894f1f Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/Mask.png differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/auth/auth--bg.svg b/research/chatwoot/app/javascript/dashboard/assets/images/auth/auth--bg.svg new file mode 100644 index 0000000..8e1a708 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/auth/auth--bg.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/auth/bottom-right.svg b/research/chatwoot/app/javascript/dashboard/assets/images/auth/bottom-right.svg new file mode 100644 index 0000000..3679676 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/auth/bottom-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/auth/signup-bg.jpg b/research/chatwoot/app/javascript/dashboard/assets/images/auth/signup-bg.jpg new file mode 100644 index 0000000..884a579 Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/auth/signup-bg.jpg differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/auth/top-left.svg b/research/chatwoot/app/javascript/dashboard/assets/images/auth/top-left.svg new file mode 100644 index 0000000..832d11b --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/auth/top-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/bottom-nav.png b/research/chatwoot/app/javascript/dashboard/assets/images/bottom-nav.png new file mode 100644 index 0000000..4fce34e Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/bottom-nav.png differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/bubble-logo.svg b/research/chatwoot/app/javascript/dashboard/assets/images/bubble-logo.svg new file mode 100644 index 0000000..1eb8c62 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/bubble-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/channels/facebook_login.png b/research/chatwoot/app/javascript/dashboard/assets/images/channels/facebook_login.png new file mode 100644 index 0000000..4538203 Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/channels/facebook_login.png differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/chat.svg b/research/chatwoot/app/javascript/dashboard/assets/images/chat.svg new file mode 100644 index 0000000..fd4f36f --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/chat.svg @@ -0,0 +1,19 @@ + + + + chat (2) + Created with Sketch. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/chatwoot_bot.png b/research/chatwoot/app/javascript/dashboard/assets/images/chatwoot_bot.png new file mode 100644 index 0000000..4b5a2d6 Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/chatwoot_bot.png differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/flag.svg b/research/chatwoot/app/javascript/dashboard/assets/images/flag.svg new file mode 100644 index 0000000..107d836 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/flag.svg @@ -0,0 +1,18 @@ + + + + flag + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/inboxes.svg b/research/chatwoot/app/javascript/dashboard/assets/images/inboxes.svg new file mode 100644 index 0000000..1114cde --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/inboxes.svg @@ -0,0 +1,25 @@ + + + + email + Created with Sketch. + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/lock.svg b/research/chatwoot/app/javascript/dashboard/assets/images/lock.svg new file mode 100644 index 0000000..f65d1c4 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/lock.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/no-chat-dark.svg b/research/chatwoot/app/javascript/dashboard/assets/images/no-chat-dark.svg new file mode 100644 index 0000000..924b07d --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/no-chat-dark.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/no-chat.svg b/research/chatwoot/app/javascript/dashboard/assets/images/no-chat.svg new file mode 100644 index 0000000..44c5a16 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/no-chat.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/no-inboxes.svg b/research/chatwoot/app/javascript/dashboard/assets/images/no-inboxes.svg new file mode 100644 index 0000000..cec37e2 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/images/no-inboxes.svg @@ -0,0 +1,22 @@ + + + + billboard + Created with Sketch. + + + + + + + + + + + + + NO INBOXES + + + + \ No newline at end of file diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/no_page_image.png b/research/chatwoot/app/javascript/dashboard/assets/images/no_page_image.png new file mode 100644 index 0000000..ee145e8 Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/no_page_image.png differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/images/typing.gif b/research/chatwoot/app/javascript/dashboard/assets/images/typing.gif new file mode 100644 index 0000000..b288d25 Binary files /dev/null and b/research/chatwoot/app/javascript/dashboard/assets/images/typing.gif differ diff --git a/research/chatwoot/app/javascript/dashboard/assets/scss/_base.scss b/research/chatwoot/app/javascript/dashboard/assets/scss/_base.scss new file mode 100644 index 0000000..84c8a4b --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/scss/_base.scss @@ -0,0 +1,199 @@ +// scss-lint:disable QualifyingElement + +// Base typography +// ------------------------- +h1, +h2, +h3, +h4, +h5, +h6 { + @apply font-medium text-n-slate-12; +} + +p { + text-rendering: optimizeLegibility; + @apply mb-2 leading-[1.65] text-sm; + + a { + @apply text-n-brand dark:text-n-brand cursor-pointer; + } +} + +a { + @apply text-sm; +} + +hr { + @apply clear-both max-w-full h-0 my-5 mx-0 border-slate-300 dark:border-slate-600; +} + +ul, +ol, +dl { + @apply list-disc list-outside leading-[1.65]; +} + +ul:not(.reset-base), +ol:not(.reset-base), +dl:not(.reset-base) { + @apply mb-0; +} + +// Button base +button { + font-family: inherit; + @apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50; +} + +// Form elements +// ------------------------- +label { + @apply text-n-slate-12 block m-0 leading-7 text-sm font-medium; +} + +.input-wrap, +.help-text { + @apply text-n-slate-11 text-sm font-medium; +} + +// Focus outline removal +.button, +textarea { + outline: none; +} + +// Field base styles (Input, TextArea, Select) +@layer components { + .field-base { + @apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6; + } + + .field-disabled { + @apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed; + } + + .field-error { + @apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8; + } +} + +$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)"; + +#{$form-input-selector} { + @apply field-base h-10; + + &[disabled] { + @apply field-disabled; + } + + &.error { + @apply field-error mb-1; + } +} + +input[type='file'] { + @apply leading-[1.15] mb-4 border-0 bg-transparent text-sm; +} + +// Select +select { + background-image: url("data:image/svg+xml;utf8,"); + background-size: 9px 6px; + + @apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3; + + &[disabled] { + @apply field-disabled; + } +} + +// Textarea +textarea { + @apply field-base h-16; + + &[disabled] { + @apply field-disabled; + } +} + +// Add mb-1 when .help-text exists within the same label container +label:has(.help-text) { + input, + textarea, + select { + margin-bottom: 0.25rem !important; + } +} + +// FormKit support +.formkit-outer[data-invalid='true'] { + #{$form-input-selector}, + textarea, + select { + @apply field-error; + } + + .formkit-message { + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; + } +} + +.error { + #{$form-input-selector}, + input:not([type]), + textarea, + select { + @apply field-error; + } + + // Only add mb-1 when .message exists within the same .error container + // And exclude no-margin from the margin-bottom + &:has(.message) { + input:not(.no-margin), + textarea, + select { + margin-bottom: 0.25rem !important; + } + } + + .message { + @apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full; + } +} + +.input-group.small { + input { + @apply text-sm h-8; + } + + .error { + @apply text-n-ruby-9 dark:text-n-ruby-9; + } +} + +// Code styling +code { + font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', + '"Liberation Mono"', '"Courier New"', 'monospace'; + @apply text-xs border-0; + + &.hljs { + @apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5; + + .hljs-number, + .hljs-string { + @apply text-n-ruby-9 dark:text-n-ruby-9; + } + + .hljs-name, + .hljs-tag { + @apply text-n-slate-11; + } + } +} + +// Table +table { + @apply border-spacing-0 text-sm w-full; +} diff --git a/research/chatwoot/app/javascript/dashboard/assets/scss/_next-colors.scss b/research/chatwoot/app/javascript/dashboard/assets/scss/_next-colors.scss new file mode 100644 index 0000000..784edce --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -0,0 +1,296 @@ +// scss-lint:disable PropertySortOrder +@layer base { + // NEXT COLORS START + :root { + // slate + --slate-1: 252 252 253; + --slate-2: 249 249 251; + --slate-3: 240 240 243; + --slate-4: 232 232 236; + --slate-5: 224 225 230; + --slate-6: 217 217 224; + --slate-7: 205 206 214; + --slate-8: 185 187 198; + --slate-9: 139 141 152; + --slate-10: 128 131 141; + --slate-11: 96 100 108; + --slate-12: 28 32 36; + + --iris-1: 253 253 255; + --iris-2: 248 248 255; + --iris-3: 240 241 254; + --iris-4: 230 231 255; + --iris-5: 218 220 255; + --iris-6: 203 205 255; + --iris-7: 184 186 248; + --iris-8: 155 158 240; + --iris-9: 91 91 214; + --iris-10: 81 81 205; + --iris-11: 87 83 198; + --iris-12: 39 41 98; + + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + + --ruby-1: 255 252 253; + --ruby-2: 255 247 248; + --ruby-3: 254 234 237; + --ruby-4: 255 220 225; + --ruby-5: 255 206 214; + --ruby-6: 248 191 200; + --ruby-7: 239 172 184; + --ruby-8: 229 146 163; + --ruby-9: 229 70 102; + --ruby-10: 220 59 93; + --ruby-11: 202 36 77; + --ruby-12: 100 23 43; + + --amber-1: 254 253 251; + --amber-2: 254 251 233; + --amber-3: 255 247 194; + --amber-4: 255 238 156; + --amber-5: 251 229 119; + --amber-6: 243 214 115; + --amber-7: 233 193 98; + --amber-8: 226 163 54; + --amber-9: 255 197 61; + --amber-10: 255 186 24; + --amber-11: 171 100 0; + --amber-12: 79 52 34; + + --teal-1: 250 254 253; + --teal-2: 243 251 249; + --teal-3: 224 248 243; + --teal-4: 204 243 234; + --teal-5: 184 234 224; + --teal-6: 161 222 210; + --teal-7: 131 205 193; + --teal-8: 83 185 171; + --teal-9: 18 165 148; + --teal-10: 13 155 138; + --teal-11: 0 133 115; + --teal-12: 13 61 56; + + --gray-1: 252 252 252; + --gray-2: 249 249 249; + --gray-3: 240 240 240; + --gray-4: 232 232 232; + --gray-5: 224 224 224; + --gray-6: 217 217 217; + --gray-7: 206 206 206; + --gray-8: 187 187 187; + --gray-9: 141 141 141; + --gray-10: 131 131 131; + --gray-11: 100 100 100; + --gray-12: 32 32 32; + + --violet-1: 253 252 254; + --violet-2: 250 248 255; + --violet-3: 244 240 254; + --violet-4: 235 228 255; + --violet-5: 225 217 255; + --violet-6: 212 202 254; + --violet-7: 194 178 248; + --violet-8: 169 153 236; + --violet-9: 110 86 207; + --violet-10: 100 84 196; + --violet-11: 101 85 183; + --violet-12: 47 38 95; + + --background-color: 247 247 247; + --surface-1: 254 254 254; + --surface-2: 255 255 255; + --surface-active: 255 255 255; + --background-input-box: 0, 0, 0, 0.03; + --text-blue: 1 22 44; + --text-purple: 2 4 49; + --text-amber: 37 24 1; + --border-container: 236 236 236; + --border-strong: 226 227 231; + --border-weak: 234 234 234; + --border-blue-strong: 18 61 117; + --solid-1: 255 255 255; + --solid-2: 255 255 255; + --solid-3: 255 255 255; + --solid-active: 255 255 255; + --solid-amber: 255 228 181; + --solid-blue: 218 236 255; + --solid-blue-2: 251 253 255; + --solid-iris: 230 231 255; + --solid-purple: 230 231 255; + --solid-red: 254 200 201; + --solid-amber-button: 255 221 141; + --card-color: 255 255 255; + --overlay: 0, 0, 0, 0.12; + --overlay-avatar: 255, 255, 255, 0.67; + --button-color: 255 255 255; + --button-hover-color: 255, 255, 255, 0.2; + --label-background: 247 247 247; + --label-border: 0, 0, 0, 0.04; + + --alpha-1: 215, 215, 215, 0.22; + --alpha-2: 196, 197, 198, 0.22; + --alpha-3: 255, 255, 255, 0.96; + --black-alpha-1: 0, 0, 0, 0.12; + --black-alpha-2: 0, 0, 0, 0.04; + --border-blue: 39, 129, 246, 0.5; + --white-alpha: 255, 255, 255, 0.8; + } + + .dark { + // slate + --slate-1: 17 17 19; + --slate-2: 24 25 27; + --slate-3: 33 34 37; + --slate-4: 39 42 45; + --slate-5: 46 49 53; + --slate-6: 54 58 63; + --slate-7: 67 72 78; + --slate-8: 90 97 105; + --slate-9: 105 110 119; + --slate-10: 119 123 132; + --slate-11: 176 180 186; + --slate-12: 237 238 240; + + --iris-1: 19 19 30; + --iris-2: 23 22 37; + --iris-3: 32 34 72; + --iris-4: 38 42 101; + --iris-5: 48 51 116; + --iris-6: 61 62 130; + --iris-7: 74 74 149; + --iris-8: 89 88 177; + --iris-9: 91 91 214; + --iris-10: 84 114 228; + --iris-11: 158 177 255; + --iris-12: 224 223 254; + + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + + --ruby-1: 25 17 19; + --ruby-2: 30 21 23; + --ruby-3: 58 20 30; + --ruby-4: 78 19 37; + --ruby-5: 94 26 46; + --ruby-6: 111 37 57; + --ruby-7: 136 52 71; + --ruby-8: 179 68 90; + --ruby-9: 229 70 102; + --ruby-10: 236 90 114; + --ruby-11: 255 148 157; + --ruby-12: 254 210 225; + + --amber-1: 22 18 12; + --amber-2: 29 24 15; + --amber-3: 48 32 8; + --amber-4: 63 39 0; + --amber-5: 77 48 0; + --amber-6: 92 61 5; + --amber-7: 113 79 25; + --amber-8: 143 100 36; + --amber-9: 255 197 61; + --amber-10: 255 214 10; + --amber-11: 255 202 22; + --amber-12: 255 231 179; + + --teal-1: 13 21 20; + --teal-2: 17 28 27; + --teal-3: 13 45 42; + --teal-4: 2 59 55; + --teal-5: 8 72 67; + --teal-6: 20 87 80; + --teal-7: 28 105 97; + --teal-8: 32 126 115; + --teal-9: 18 165 148; + --teal-10: 14 179 158; + --teal-11: 11 216 182; + --teal-12: 173 240 221; + + --gray-1: 17 17 17; + --gray-2: 25 25 25; + --gray-3: 34 34 34; + --gray-4: 42 42 42; + --gray-5: 49 49 49; + --gray-6: 58 58 58; + --gray-7: 72 72 72; + --gray-8: 96 96 96; + --gray-9: 110 110 110; + --gray-10: 123 123 123; + --gray-11: 180 180 180; + --gray-12: 238 238 238; + + --violet-1: 20 17 31; + --violet-2: 27 21 37; + --violet-3: 41 31 67; + --violet-4: 50 37 85; + --violet-5: 60 46 105; + --violet-6: 71 56 135; + --violet-7: 86 70 151; + --violet-8: 110 86 171; + --violet-9: 110 86 207; + --violet-10: 125 109 217; + --violet-11: 169 153 236; + --violet-12: 226 221 254; + + --background-color: 28 29 32; + --surface-1: 20 21 23; + --surface-2: 22 23 26; + --surface-active: 53 57 66; + --background-input-box: 255, 255, 255, 0.02; + --text-blue: 213 234 255; + --text-purple: 232 233 254; + --text-amber: 255 247 234; + --border-strong: 46 45 50; + --border-weak: 31 31 37; + --border-blue-strong: 201 226 255; + --solid-1: 23 23 26; + --solid-2: 29 30 36; + --solid-3: 44 45 54; + --solid-active: 53 57 66; + --solid-amber: 56 50 41; + --solid-blue: 15 57 102; + --solid-blue-2: 26 29 35; + --solid-iris: 38 42 101; + --solid-purple: 51 51 107; + --solid-red: 90 33 34; + --solid-amber-button: 255 221 141; + --card-color: 28 30 34; + --overlay: 0, 0, 0, 0.4; + --overlay-avatar: 0, 0, 0, 0.05; + --button-color: 42 43 51; + --button-hover-color: 0, 0, 0, 0.15; + --label-background: 36 38 45; + --label-border: 255, 255, 255, 0.03; + + --alpha-1: 35, 36, 42, 0.8; + --alpha-2: 147, 153, 176, 0.12; + --alpha-3: 33, 34, 38, 0.95; + --black-alpha-1: 0, 0, 0, 0.3; + --black-alpha-2: 0, 0, 0, 0.2; + --border-blue: 39, 129, 246, 0.5; + --border-container: 255, 255, 255, 0; + --white-alpha: 255, 255, 255, 0.1; + } +} +// NEXT COLORS END diff --git a/research/chatwoot/app/javascript/dashboard/assets/scss/_woot.scss b/research/chatwoot/app/javascript/dashboard/assets/scss/_woot.scss new file mode 100644 index 0000000..27764d1 --- /dev/null +++ b/research/chatwoot/app/javascript/dashboard/assets/scss/_woot.scss @@ -0,0 +1,148 @@ +// scss-lint:disable SpaceAfterPropertyColon +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; + +@import 'shared/assets/fonts/InterDisplay/inter-display'; +@import 'shared/assets/fonts/inter'; + +// Next Colors +@import 'next-colors'; + +// Base styles for elements +@import 'base'; + +// Plugins +@import 'plugins/date-picker'; + +html, +body { + font-family: + 'Inter', + -apple-system, + system-ui, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Tahoma, + Arial, + sans-serif !important; + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + height: 100%; + margin: 0; + padding: 0; + width: 100%; +} + +.tooltip { + @apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96; +} + +#app { + @apply h-full w-full; +} + +.custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; +} + +.dark .custom-dashed-border { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E"); +} + +@layer utilities { + /* Hide scrollbar for Chrome, Safari and Opera */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + /* Hide scrollbar for IE, Edge and Firefox */ + .no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + /** + * ============================================================================ + * TYPOGRAPHY UTILITIES + * ============================================================================ + * + * | Class | Use Case | + * |--------------------|----------------------------------------------------| + * | .text-body-main |

, , general body text | + * | .text-body-para |

for paragraphs, larger text blocks | + * | .text-heading-1 |

, page titles, panel headers | + * | .text-heading-2 |

, section headings, card titles | + * | .text-heading-3 |

, card headings, breadcrumbs, subsections | + * | .text-label |