From 9749da5ae7e82e8fea8d30a08da17522a9a872e8 Mon Sep 17 00:00:00 2001 From: Yuri Date: Sun, 25 Oct 2026 23:11:04 +0400 Subject: [PATCH] =?UTF-8?q?Lead=20Radar:=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .dockerignore | 9 + .env.example | 31 + .gitignore | 10 + README.md | 145 + apps/api/drizzle.config.ts | 7 + apps/api/drizzle/0000_busy_kat_farrell.sql | 354 ++ .../drizzle/0001_left_lady_deathstrike.sql | 9 + apps/api/drizzle/0002_black_shocker.sql | 5 + apps/api/drizzle/0003_sharp_giant_girl.sql | 8 + apps/api/drizzle/0004_eager_mockingbird.sql | 32 + apps/api/drizzle/0005_left_slapstick.sql | 18 + apps/api/drizzle/0006_thick_umar.sql | 58 + apps/api/drizzle/0007_chilly_maddog.sql | 3 + apps/api/drizzle/0008_concerned_cardiac.sql | 1 + .../drizzle/0009_bizarre_randall_flagg.sql | 20 + apps/api/drizzle/0010_wakeful_exodus.sql | 2 + apps/api/drizzle/0011_soft_edwin_jarvis.sql | 1 + apps/api/drizzle/0012_organic_thing.sql | 19 + apps/api/drizzle/0013_shallow_kabuki.sql | 35 + apps/api/drizzle/0014_modern_gabe_jones.sql | 5 + .../api/drizzle/0015_supreme_clint_barton.sql | 3 + apps/api/drizzle/0016_massive_maria_hill.sql | 2 + apps/api/drizzle/0017_open_gabe_jones.sql | 2 + apps/api/drizzle/0018_elite_magdalene.sql | 37 + apps/api/drizzle/meta/0000_snapshot.json | 2624 ++++++++++ apps/api/drizzle/meta/0001_snapshot.json | 2630 ++++++++++ apps/api/drizzle/meta/0002_snapshot.json | 2662 ++++++++++ apps/api/drizzle/meta/0003_snapshot.json | 2694 ++++++++++ apps/api/drizzle/meta/0004_snapshot.json | 2935 +++++++++++ apps/api/drizzle/meta/0005_snapshot.json | 3071 ++++++++++++ apps/api/drizzle/meta/0006_snapshot.json | 3515 +++++++++++++ apps/api/drizzle/meta/0007_snapshot.json | 3534 +++++++++++++ apps/api/drizzle/meta/0008_snapshot.json | 3540 +++++++++++++ apps/api/drizzle/meta/0009_snapshot.json | 3673 ++++++++++++++ apps/api/drizzle/meta/0010_snapshot.json | 3685 ++++++++++++++ apps/api/drizzle/meta/0011_snapshot.json | 3691 ++++++++++++++ apps/api/drizzle/meta/0012_snapshot.json | 3750 ++++++++++++++ apps/api/drizzle/meta/0013_snapshot.json | 4043 +++++++++++++++ apps/api/drizzle/meta/0014_snapshot.json | 4075 +++++++++++++++ apps/api/drizzle/meta/0015_snapshot.json | 4093 +++++++++++++++ apps/api/drizzle/meta/0016_snapshot.json | 4105 +++++++++++++++ apps/api/drizzle/meta/0017_snapshot.json | 4117 +++++++++++++++ apps/api/drizzle/meta/0018_snapshot.json | 4401 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 139 + apps/api/package.json | 27 + apps/api/src/admin-listings.ts | 93 + apps/api/src/analytics/routes.ts | 20 + apps/api/src/analytics/service.ts | 281 ++ apps/api/src/app.int.test.ts | 253 + apps/api/src/app.ts | 871 ++++ apps/api/src/archive.int.test.ts | 460 ++ apps/api/src/archive/routes.ts | 152 + apps/api/src/auth/password.ts | 18 + apps/api/src/auth/sessions.ts | 33 + apps/api/src/auth/telegram.ts | 82 + apps/api/src/barev.int.test.ts | 128 + apps/api/src/barev/routes.ts | 37 + apps/api/src/barev/service.ts | 260 + apps/api/src/billing.int.test.ts | 398 ++ apps/api/src/billing/provider.ts | 27 + apps/api/src/billing/routes.ts | 297 ++ apps/api/src/billing/service.ts | 479 ++ apps/api/src/cli/create-superadmin.ts | 25 + apps/api/src/clients.int.test.ts | 698 +++ apps/api/src/clients/routes.ts | 427 ++ apps/api/src/clients/service.ts | 305 ++ apps/api/src/companies/companies.service.ts | 147 + apps/api/src/db/client.ts | 12 + apps/api/src/db/migrate.ts | 14 + apps/api/src/db/schema.ts | 785 +++ apps/api/src/deals/service.ts | 197 + apps/api/src/deep-pass.int.test.ts | 60 + apps/api/src/departments.int.test.ts | 259 + apps/api/src/departments/routes.ts | 136 + apps/api/src/group/service.ts | 222 + apps/api/src/ingest/service.ts | 506 ++ apps/api/src/ingest/watchdog.ts | 59 + apps/api/src/intake/service.ts | 412 ++ apps/api/src/intake/texts.ts | 97 + apps/api/src/leads.int.test.ts | 715 +++ apps/api/src/leads/routes.ts | 611 +++ apps/api/src/leads/service.ts | 792 +++ apps/api/src/lib/commission.ts | 51 + apps/api/src/lib/config.ts | 41 + apps/api/src/lib/errors.ts | 15 + apps/api/src/lib/mailer.ts | 16 + apps/api/src/lib/patch.ts | 9 + apps/api/src/main.ts | 99 + apps/api/src/parser/feed-source.test.ts | 86 + apps/api/src/parser/feed-source.ts | 141 + apps/api/src/parser/listam-source.test.ts | 465 ++ apps/api/src/parser/listam-source.ts | 733 +++ apps/api/src/parser/settings.ts | 54 + apps/api/src/parser/source.ts | 60 + apps/api/src/payroll.ts | 411 ++ apps/api/src/pipelines/routes.ts | 224 + apps/api/src/pipelines/service.ts | 29 + apps/api/src/reports.ts | 113 + apps/api/src/showcase.int.test.ts | 419 ++ apps/api/src/showcase/media.ts | 47 + apps/api/src/showcase/routes.ts | 77 + apps/api/src/showcase/service.ts | 361 ++ apps/api/src/showcase/text.ts | 129 + apps/api/src/support.int.test.ts | 213 + apps/api/src/support/routes.ts | 150 + apps/api/src/support/service.ts | 169 + apps/api/src/telegram/bot.ts | 188 + apps/api/src/telegram/client.ts | 153 + apps/api/src/telegram/outbox.ts | 207 + apps/api/src/telegram/start.ts | 89 + apps/api/src/telegram/texts.ts | 247 + apps/api/src/testing.ts | 138 + apps/api/src/watchdog.int.test.ts | 51 + apps/api/tsconfig.json | 1 + apps/api/vitest.config.ts | 4 + apps/web/index.html | 17 + apps/web/package.json | 28 + apps/web/public/favicon.svg | 1 + apps/web/src/App.tsx | 110 + apps/web/src/account/BillingPage.tsx | 301 ++ apps/web/src/account/BotStart.tsx | 87 + apps/web/src/account/InvoicesPage.tsx | 177 + apps/web/src/account/MessagesPage.tsx | 162 + apps/web/src/account/SupportPage.tsx | 259 + apps/web/src/account/TelegramOnly.tsx | 39 + apps/web/src/account/Wizard.tsx | 130 + apps/web/src/account/i18n.ts | 262 + apps/web/src/api.ts | 136 + apps/web/src/components/Shell.tsx | 202 + apps/web/src/components/ui.tsx | 102 + apps/web/src/crm/AnalyticsPage.tsx | 238 + apps/web/src/crm/ArchivePage.tsx | 184 + apps/web/src/crm/ClientDrawer.tsx | 228 + apps/web/src/crm/ClientsPage.tsx | 366 ++ apps/web/src/crm/FiltersPage.tsx | 278 ++ apps/web/src/crm/LeadDrawer.tsx | 468 ++ apps/web/src/crm/LeadsPage.tsx | 458 ++ apps/web/src/crm/ListingsPage.tsx | 197 + apps/web/src/crm/ParserPage.tsx | 85 + apps/web/src/crm/ParserSettings.tsx | 116 + apps/web/src/crm/PipelineSettings.tsx | 189 + apps/web/src/crm/api.ts | 185 + apps/web/src/crm/commission.tsx | 122 + apps/web/src/crm/departments.tsx | 109 + apps/web/src/crm/household.tsx | 234 + apps/web/src/crm/i18n.ts | 726 +++ apps/web/src/crm/parts.tsx | 450 ++ apps/web/src/crm/payroll.tsx | 572 +++ apps/web/src/crm/showcase.tsx | 84 + apps/web/src/crm/trend.tsx | 176 + apps/web/src/hooks.ts | 44 + apps/web/src/i18n.tsx | 348 ++ apps/web/src/index.css | 128 + apps/web/src/main.tsx | 36 + apps/web/src/pages/Admin.tsx | 179 + apps/web/src/pages/AuthPages.tsx | 232 + apps/web/src/pages/Onboarding.tsx | 148 + apps/web/src/pages/Settings.tsx | 219 + apps/web/src/pages/Team.tsx | 159 + apps/web/src/public/ShowcasePage.tsx | 166 + apps/web/src/telegram.ts | 50 + apps/web/tsconfig.json | 13 + apps/web/vite.config.ts | 9 + deploy.sh | 48 + deploy/.env.production.example | 28 + deploy/Dockerfile | 23 + deploy/docker-compose.prod.yml | 92 + deploy/nginx.conf | 32 + docker-compose.yml | 22 + docs/ACCEPTANCE.md | 38 + docs/BILLING.md | 60 + docs/DEPLOY.md | 47 + docs/MIGRATION-YANDP-TECH.md | 58 + docs/PARSER.md | 101 + docs/brand/bot-avatar.png | Bin 0 -> 145311 bytes docs/brand/bot-avatar.svg | 27 + package.json | 25 + packages/shared/package.json | 16 + packages/shared/src/agent-phrases.ts | 50 + packages/shared/src/commission.ts | 58 + packages/shared/src/filters.ts | 124 + packages/shared/src/household.ts | 45 + packages/shared/src/index.ts | 11 + packages/shared/src/pay.ts | 75 + packages/shared/src/phone.ts | 22 + packages/shared/src/pipelines.ts | 64 + packages/shared/src/plans.ts | 66 + packages/shared/src/regions.ts | 57 + packages/shared/src/rent-term.ts | 3 + packages/shared/src/roles.ts | 6 + packages/shared/src/shared.test.ts | 113 + packages/shared/tsconfig.json | 1 + pnpm-lock.yaml | 3031 ++++++++++++ pnpm-workspace.yaml | 3 + scripts/delete-all-companies.sql | 46 + scripts/seed-demo-backdate.sql | 52 + scripts/seed-demo-clients.mjs | 98 + scripts/seed-demo-one-finish.sql | 100 + scripts/seed-demo-one.mjs | 262 + scripts/seed-demo.mjs | 181 + tsconfig.base.json | 13 + 201 files changed, 97685 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/drizzle.config.ts create mode 100644 apps/api/drizzle/0000_busy_kat_farrell.sql create mode 100644 apps/api/drizzle/0001_left_lady_deathstrike.sql create mode 100644 apps/api/drizzle/0002_black_shocker.sql create mode 100644 apps/api/drizzle/0003_sharp_giant_girl.sql create mode 100644 apps/api/drizzle/0004_eager_mockingbird.sql create mode 100644 apps/api/drizzle/0005_left_slapstick.sql create mode 100644 apps/api/drizzle/0006_thick_umar.sql create mode 100644 apps/api/drizzle/0007_chilly_maddog.sql create mode 100644 apps/api/drizzle/0008_concerned_cardiac.sql create mode 100644 apps/api/drizzle/0009_bizarre_randall_flagg.sql create mode 100644 apps/api/drizzle/0010_wakeful_exodus.sql create mode 100644 apps/api/drizzle/0011_soft_edwin_jarvis.sql create mode 100644 apps/api/drizzle/0012_organic_thing.sql create mode 100644 apps/api/drizzle/0013_shallow_kabuki.sql create mode 100644 apps/api/drizzle/0014_modern_gabe_jones.sql create mode 100644 apps/api/drizzle/0015_supreme_clint_barton.sql create mode 100644 apps/api/drizzle/0016_massive_maria_hill.sql create mode 100644 apps/api/drizzle/0017_open_gabe_jones.sql create mode 100644 apps/api/drizzle/0018_elite_magdalene.sql create mode 100644 apps/api/drizzle/meta/0000_snapshot.json create mode 100644 apps/api/drizzle/meta/0001_snapshot.json create mode 100644 apps/api/drizzle/meta/0002_snapshot.json create mode 100644 apps/api/drizzle/meta/0003_snapshot.json create mode 100644 apps/api/drizzle/meta/0004_snapshot.json create mode 100644 apps/api/drizzle/meta/0005_snapshot.json create mode 100644 apps/api/drizzle/meta/0006_snapshot.json create mode 100644 apps/api/drizzle/meta/0007_snapshot.json create mode 100644 apps/api/drizzle/meta/0008_snapshot.json create mode 100644 apps/api/drizzle/meta/0009_snapshot.json create mode 100644 apps/api/drizzle/meta/0010_snapshot.json create mode 100644 apps/api/drizzle/meta/0011_snapshot.json create mode 100644 apps/api/drizzle/meta/0012_snapshot.json create mode 100644 apps/api/drizzle/meta/0013_snapshot.json create mode 100644 apps/api/drizzle/meta/0014_snapshot.json create mode 100644 apps/api/drizzle/meta/0015_snapshot.json create mode 100644 apps/api/drizzle/meta/0016_snapshot.json create mode 100644 apps/api/drizzle/meta/0017_snapshot.json create mode 100644 apps/api/drizzle/meta/0018_snapshot.json create mode 100644 apps/api/drizzle/meta/_journal.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/admin-listings.ts create mode 100644 apps/api/src/analytics/routes.ts create mode 100644 apps/api/src/analytics/service.ts create mode 100644 apps/api/src/app.int.test.ts create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/archive.int.test.ts create mode 100644 apps/api/src/archive/routes.ts create mode 100644 apps/api/src/auth/password.ts create mode 100644 apps/api/src/auth/sessions.ts create mode 100644 apps/api/src/auth/telegram.ts create mode 100644 apps/api/src/barev.int.test.ts create mode 100644 apps/api/src/barev/routes.ts create mode 100644 apps/api/src/barev/service.ts create mode 100644 apps/api/src/billing.int.test.ts create mode 100644 apps/api/src/billing/provider.ts create mode 100644 apps/api/src/billing/routes.ts create mode 100644 apps/api/src/billing/service.ts create mode 100644 apps/api/src/cli/create-superadmin.ts create mode 100644 apps/api/src/clients.int.test.ts create mode 100644 apps/api/src/clients/routes.ts create mode 100644 apps/api/src/clients/service.ts create mode 100644 apps/api/src/companies/companies.service.ts create mode 100644 apps/api/src/db/client.ts create mode 100644 apps/api/src/db/migrate.ts create mode 100644 apps/api/src/db/schema.ts create mode 100644 apps/api/src/deals/service.ts create mode 100644 apps/api/src/deep-pass.int.test.ts create mode 100644 apps/api/src/departments.int.test.ts create mode 100644 apps/api/src/departments/routes.ts create mode 100644 apps/api/src/group/service.ts create mode 100644 apps/api/src/ingest/service.ts create mode 100644 apps/api/src/ingest/watchdog.ts create mode 100644 apps/api/src/intake/service.ts create mode 100644 apps/api/src/intake/texts.ts create mode 100644 apps/api/src/leads.int.test.ts create mode 100644 apps/api/src/leads/routes.ts create mode 100644 apps/api/src/leads/service.ts create mode 100644 apps/api/src/lib/commission.ts create mode 100644 apps/api/src/lib/config.ts create mode 100644 apps/api/src/lib/errors.ts create mode 100644 apps/api/src/lib/mailer.ts create mode 100644 apps/api/src/lib/patch.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/parser/feed-source.test.ts create mode 100644 apps/api/src/parser/feed-source.ts create mode 100644 apps/api/src/parser/listam-source.test.ts create mode 100644 apps/api/src/parser/listam-source.ts create mode 100644 apps/api/src/parser/settings.ts create mode 100644 apps/api/src/parser/source.ts create mode 100644 apps/api/src/payroll.ts create mode 100644 apps/api/src/pipelines/routes.ts create mode 100644 apps/api/src/pipelines/service.ts create mode 100644 apps/api/src/reports.ts create mode 100644 apps/api/src/showcase.int.test.ts create mode 100644 apps/api/src/showcase/media.ts create mode 100644 apps/api/src/showcase/routes.ts create mode 100644 apps/api/src/showcase/service.ts create mode 100644 apps/api/src/showcase/text.ts create mode 100644 apps/api/src/support.int.test.ts create mode 100644 apps/api/src/support/routes.ts create mode 100644 apps/api/src/support/service.ts create mode 100644 apps/api/src/telegram/bot.ts create mode 100644 apps/api/src/telegram/client.ts create mode 100644 apps/api/src/telegram/outbox.ts create mode 100644 apps/api/src/telegram/start.ts create mode 100644 apps/api/src/telegram/texts.ts create mode 100644 apps/api/src/testing.ts create mode 100644 apps/api/src/watchdog.int.test.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/account/BillingPage.tsx create mode 100644 apps/web/src/account/BotStart.tsx create mode 100644 apps/web/src/account/InvoicesPage.tsx create mode 100644 apps/web/src/account/MessagesPage.tsx create mode 100644 apps/web/src/account/SupportPage.tsx create mode 100644 apps/web/src/account/TelegramOnly.tsx create mode 100644 apps/web/src/account/Wizard.tsx create mode 100644 apps/web/src/account/i18n.ts create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/components/Shell.tsx create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/crm/AnalyticsPage.tsx create mode 100644 apps/web/src/crm/ArchivePage.tsx create mode 100644 apps/web/src/crm/ClientDrawer.tsx create mode 100644 apps/web/src/crm/ClientsPage.tsx create mode 100644 apps/web/src/crm/FiltersPage.tsx create mode 100644 apps/web/src/crm/LeadDrawer.tsx create mode 100644 apps/web/src/crm/LeadsPage.tsx create mode 100644 apps/web/src/crm/ListingsPage.tsx create mode 100644 apps/web/src/crm/ParserPage.tsx create mode 100644 apps/web/src/crm/ParserSettings.tsx create mode 100644 apps/web/src/crm/PipelineSettings.tsx create mode 100644 apps/web/src/crm/api.ts create mode 100644 apps/web/src/crm/commission.tsx create mode 100644 apps/web/src/crm/departments.tsx create mode 100644 apps/web/src/crm/household.tsx create mode 100644 apps/web/src/crm/i18n.ts create mode 100644 apps/web/src/crm/parts.tsx create mode 100644 apps/web/src/crm/payroll.tsx create mode 100644 apps/web/src/crm/showcase.tsx create mode 100644 apps/web/src/crm/trend.tsx create mode 100644 apps/web/src/hooks.ts create mode 100644 apps/web/src/i18n.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/Admin.tsx create mode 100644 apps/web/src/pages/AuthPages.tsx create mode 100644 apps/web/src/pages/Onboarding.tsx create mode 100644 apps/web/src/pages/Settings.tsx create mode 100644 apps/web/src/pages/Team.tsx create mode 100644 apps/web/src/public/ShowcasePage.tsx create mode 100644 apps/web/src/telegram.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100755 deploy.sh create mode 100644 deploy/.env.production.example create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.prod.yml create mode 100644 deploy/nginx.conf create mode 100644 docker-compose.yml create mode 100644 docs/ACCEPTANCE.md create mode 100644 docs/BILLING.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/MIGRATION-YANDP-TECH.md create mode 100644 docs/PARSER.md create mode 100644 docs/brand/bot-avatar.png create mode 100644 docs/brand/bot-avatar.svg create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/agent-phrases.ts create mode 100644 packages/shared/src/commission.ts create mode 100644 packages/shared/src/filters.ts create mode 100644 packages/shared/src/household.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/pay.ts create mode 100644 packages/shared/src/phone.ts create mode 100644 packages/shared/src/pipelines.ts create mode 100644 packages/shared/src/plans.ts create mode 100644 packages/shared/src/regions.ts create mode 100644 packages/shared/src/rent-term.ts create mode 100644 packages/shared/src/roles.ts create mode 100644 packages/shared/src/shared.test.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/delete-all-companies.sql create mode 100644 scripts/seed-demo-backdate.sql create mode 100644 scripts/seed-demo-clients.mjs create mode 100644 scripts/seed-demo-one-finish.sql create mode 100644 scripts/seed-demo-one.mjs create mode 100644 scripts/seed-demo.mjs create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..69b3a71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +**/node_modules +**/dist +.env +.env.* +!.env.example +.git +.DS_Store +**/data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..723987b --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Postgres +POSTGRES_USER=leadradar +POSTGRES_PASSWORD=change-me +POSTGRES_DB=leadradar +POSTGRES_PORT=5433 +DATABASE_URL=postgres://leadradar:change-me@localhost:5433/leadradar + +# API +API_PORT=3100 +# Адрес кабинета: используется в ссылках-приглашениях и письмах +APP_URL=http://localhost:5174 + +# Telegram-бот (токен выдаёт @BotFather). Без токена вход через Telegram выключен. +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= + +# Клиентский бот: анкета соискателя. Без токена анкета выключена. +CLIENT_BOT_TOKEN= +CLIENT_BOT_USERNAME= + +# Страница подборки: обрезка фото сверху (%), папка для готовых фото, ключ OpenRouter для текста (необязательно) +CROP_TOP_PERCENT=8 +MEDIA_DIR= +OPENROUTER_API_KEY= +OPENROUTER_MODEL= + +# Источник объявлений: listam — снимки страниц list.am через ридер; feed — JSON-фид по LISTING_FEED_URL +LISTING_SOURCE=listam +LISTING_FEED_URL= +LISTING_FEED_TOKEN= +JINA_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eef034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +.env +.env.* +!.env.example +!deploy/.env.production.example +.DS_Store +data/ +*.log +*.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..81d9e3e --- /dev/null +++ b/README.md @@ -0,0 +1,145 @@ +# Y&P Lead Radar + +Сервис находит новые объявления на List.am по фильтрам клиента, доставляет их агентам в Telegram +и ведёт каждый объект как лид в CRM до сделки. Домен: `listcrm.yandpconsulting.com`. + +Написан с нуля по ТЗ. Из прежней CRM (`../crmyerevan-…`) взят только модуль парсера — +см. [docs/PARSER.md](docs/PARSER.md). + +## Структура + +| Путь | Назначение | +| ----------------- | -------------------------------------------------------------------------- | +| `apps/api` | Бэкенд: Fastify, Postgres (Drizzle). Один API для веб-кабинета и Mini App | +| `apps/web` | Веб-кабинет и Mini App: React, Tailwind, тема сайта yandpconsulting.com | +| `packages/shared` | Тарифы и лимиты, роли, воронки по умолчанию, сопоставление с фильтрами | + +## Запуск на своём компьютере + +Нужны Node 22+, pnpm 10, Docker. + +```bash +cp .env.example .env +pnpm install +pnpm db:up # Postgres в Docker, порт 5433 +pnpm db:migrate +pnpm --filter @lr/api superadmin "Имя" # печатает пароль суперадмина +pnpm dev:api # http://localhost:3100 +pnpm dev:web # http://localhost:5174 +``` + +Проверки: `pnpm typecheck && pnpm test` (тестам API нужен запущенный Postgres и база +`leadradar_test`). + +После изменения `apps/api/src/db/schema.ts`: `pnpm db:generate && pnpm db:migrate` — появится новый +файл миграции в `apps/api/drizzle/`; старые файлы миграций не правятся и не удаляются. + +## Что готово + +- Схема данных всех сущностей ТЗ (раздел 9). +- Вход по email и паролю, через Telegram Login и Telegram Mini App; подтверждение email и + сброс пароля (письма пока пишутся в лог API — почтовый сервис не выбран). +- Компании, тарифы и лимит участников, роли, приглашения, удаление агента. +- Состояние подписки: активна → просрочена (15 дней) → только чтение. +- Суперадмин: компании, тариф, дата окончания, отсрочка, вход в кабинет, состояние парсера. +- **Парсер → общая база → фильтры → лиды**: все категории недвижимости list.am, в базу идут + объявления не старше 3 дней, оттуда — клиентам по общим и личным фильтрам. +- **Бот**: уведомление о лиде с кнопкой «Беру», атомарный захват, «Взял(а) Имя» у остальных, + привязка Telegram, /pause и /resume, тихие часы. Очередь сообщений с повторами и лимитом темпа. +- **Пул**: возврат через 24 часа без смены статуса, предупреждение за 2 часа, повторное «Беру». +- **Воронка объекта**: статусы, отказ с причиной, сделка с комиссией, назначение и + переназначение, комментарии, напоминания, история, «Это агент», лиды вручную. +- **Воронка клиента**: покупатели и арендаторы, запрос клиента, подбор подходящих объектов из CRM, + отметки «предложен» и «показан», переназначение руководителем, напоминания в боте. +- **Общая сделка**: объект и клиент из CRM закрываются один раз, комиссия записывается одной + строкой из двух частей: с собственника — агенту объекта, с покупателя или арендатора — агенту + клиента; если стороны в CRM нет, обе части получает агент второй стороны. Суммы и дату исправляет + руководитель. +- **Оплата агентов**: оклад, процент с части комиссии агента (отдельно для продажи и аренды) или + оклад + процент; план на месяц (сделки, комиссия) и бонус за него — суммой или надбавкой к + проценту. Ведомость «Кому сколько платить» за месяц; строка сотрудника раскрывает его сделки, + начисления, ручные записи (премия, штраф, аванс) и отметку «выплачено». Выплата закрывает месяц: + он считается по условиям и курсам на день выплаты, а изменения его сделок и недоплата переходят + перерасчётом в ближайший месяц без выплаты. Агент видит свои условия, сделки, начисления и историю + выплат в «Настройках». +- **Динамика и рейтинг**: сделки и комиссия по месяцам — по компании и по каждому сотруднику + (агент видит только свою); рейтинг месяца виден всем: место, число сделок, выполнение плана, + без сумм. Ведомость со сделками выгружается файлом для Excel. +- **Суперадмину в Telegram** приходит уведомление о каждой новой регистрации и о компании, + созданной самостоятельно: имя, email, Telegram. **Приветствие бота** (ответ на /start тому, кто + ещё не привязал Telegram): текст на трёх языках и до 10 файлов — в разделе «Сообщения». Следом бот + спрашивает язык кнопками; выбор запоминается, на нём бот отвечает и создаётся аккаунт. +- **Отчёты в Telegram** (включает руководитель в настройках компании): итоги недели по + понедельникам и итоги месяца с ведомостью первого числа, в 9 утра по Еревану. +- **Настройка воронок**: руководитель переименовывает, добавляет, удаляет и переставляет + промежуточные статусы и причины отказа в обеих воронках. +- **База** (Team, Agency): объекты и клиенты (покупатели, арендаторы), закрытые сделкой или отказом; у клиента — связанные + объекты. Раз в сутки объекты из CRM проверяются на list.am: снятое объявление получает отметку + и дату. Повторно опубликованный объект приходит новым лидом с блоком «Уже был в работе». +- **Аналитика** (Team, Agency, руководителю): сводка, воронка, таблица по агентам (взято, скорость + реакции, возвраты в пул, конверсия по этапам, сделки, комиссии), причины отказа, выгрузка в CSV; + отдельно по объектам и по клиентам. +- **Суперадмин — «Объявления»**: вся общая база объявлений с поиском, фильтрами и карточкой + «кому ушло лидом». **Настройки парсера** в админке: включение, свежесть в днях, пауза, число + страниц, категории, ключ ридера Jina, проверка снятых объявлений — без перезапуска сервиса. +- **Биллинг**: счета за установку, фикс и «%» с расшифровкой по сделкам; смена тарифа со + следующего месяца; напоминания за 7, 3 и 1 день и при просрочке; ручная отметка оплаты + суперадмином; контракт платёжного провайдера для онлайн-оплаты — см. [docs/BILLING.md](docs/BILLING.md). +- **Суперадмин — «Счета» и «Сообщения»**: сводка (активные компании, выручка за месяц, просрочки), + счета всех компаний, курсы валют; сообщения в Telegram всем, компании, руководителям или одному + пользователю с журналом отправок. +- **Поддержка**: всё, что человек пишет боту (текст, фото, файл), попадает в обращение. Суперадмин + отвечает в разделе «Поддержка» — ответ уходит в тот же чат — и закрывает кнопкой «Проблема решена». + В кабинете у пользователей есть раздел «Поддержка» с подсказкой, как написать. +- **Характеристики объявления**: со страницы List.am берутся удобства по разделам, «можно с детьми», + «можно с животными», мебель, ремонт, предоплата и остальные поля. Видны в карточке объекта; + правила про детей и животных — ещё и в уведомлении бота. Телефон автора не собирается. +- **Кто будет жить** (карточка клиента): один, с другом, пара; дети и животные с количеством и + возрастом; до 10 фото. Подбор не предлагает объекты, где с детьми или животными прямо нельзя. +- **Анкета соискателя**: у каждого агента своя ссылка на клиентский бот (команда `/anketa` в основном + боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент, + закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?». +- **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту, + и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит + создателю ссылки в бот и появляется в карточке клиента. +- **Фото и текст на странице клиента** (логика из бота `list-am-bot`): для объектов, которые показывают + клиенту, догружается полный набор фото объявления; фото отдаёт сам сервис, срезая сверху 8 % высоты + (`CROP_TOP_PERCENT`); текст собирается по шаблону-карточке, а с ключом `OPENROUTER_API_KEY` + дополняется абзацем из описания. Адреса в тексте нет. +- **Карточка объекта**: все фото объявления (обрезанные, догружаются в фоне после первого открытия), + «Описание для клиента» с кнопкой копирования и поле «Телефон собственника» — номер агент берёт из + объявления сам; он сохраняется в карточке и, если в общей базе его не было, в базе объявлений. +- **Комиссия агентства**: руководитель задаёт в настройках процент или фикс — отдельно для объектов и + клиентов, продажи и аренды. В карточке видно условие и ожидаемую сумму; агент может поменять условие + для своей карточки. При закрытии сделки сумма комиссии подставляется по условию. +- **Оплата агентов**: руководитель задаёт каждому сотруднику оклад, процент с комиссии по его сделкам + или оклад + процент («Команда»). Там же таблица «Кому сколько платить» за выбранный месяц; агент + видит свои условия и начисления в настройках. +- **Отделы** (раздел «Команда»): руководитель создаёт отделы, назначает и снимает сотрудников, задаёт фильтры + отдела. У отдела свои объекты и клиенты: сотрудник видит только данные своего отдела, руководитель и + суперадмин — все отделы (с выбором отдела в списках). Одно объявление под фильтрами двух отделов приходит + каждому своей карточкой; уведомления — сотрудникам отдела. При смене отдела объекты и клиенты сотрудника + переходят с ним. Удалить можно отдел без сотрудников, клиентов и объектов в работе. +- **Рабочий список объектов** показывает только актуальное: снятые с List.am и закрытые сделкой или отказом + объекты не попадают в список, поиск, подбор, страницу клиента и витрину — они остаются в базе и в «Базе» + (фильтр «Сняты с List.am»). +- **Избранное и «Не просмотренные»**: звёздочка в списке и карточке, у каждого своя; фильтры сочетаются со + всеми остальными. **Цена в АМД или ДОЛ**: границы задаются в выбранной валюте, цены объектов в другой + валюте пересчитываются по курсу из админки («Счета»); при смене валюты введённые границы пересчитываются. +- **«Отправить в бот»** в карточке объекта: фото, описание и комиссия приходят основным ботом в личный чат + того, кто нажал. Без привязанного Telegram — инструкция и кнопка привязки. +- **Демо-компании**: `scripts/seed-demo.mjs` создаёт «Демо: Аренда» и «Демо: Продажа» на настоящих + объявлениях из базы, `scripts/seed-demo-backdate.sql` раскладывает их историю по трём неделям. +- **Мастер первой настройки**: первый фильтр, привязка бота, приглашение агентов. +- **Mini App**: тот же кабинет в Telegram с входом без пароля и нижней панелью разделов. + Тариф Solo в браузере видит только подписку и профиль — рабочие разделы открываются в Telegram. +- Веб-кабинет: объекты (таблица и канбан), карточка, клиенты, база, аналитика, фильтры, команда, + настройки, подписка, админка; три языка, тёмная и светлая тема. +- **Боевая сборка**: Docker-образы, ежедневная резервная копия, скрипт выкладки — + см. [docs/DEPLOY.md](docs/DEPLOY.md). + +## Что дальше + +1. Выкладка на VPS по [docs/DEPLOY.md](docs/DEPLOY.md): домен, боевой бот, первая выкладка. +2. Ручная приёмка на боевом стенде — что осталось, перечислено в [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md). +3. Платёжный провайдер, почтовый сервис, вычитка армянских текстов. diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..56b23db --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', +}); diff --git a/apps/api/drizzle/0000_busy_kat_farrell.sql b/apps/api/drizzle/0000_busy_kat_farrell.sql new file mode 100644 index 0000000..39ae78a --- /dev/null +++ b/apps/api/drizzle/0000_busy_kat_farrell.sql @@ -0,0 +1,354 @@ +CREATE TABLE "admin_messages" ( + "id" serial PRIMARY KEY NOT NULL, + "text" text NOT NULL, + "audience" text NOT NULL, + "company_id" integer, + "user_id" integer, + "recipients" integer DEFAULT 0 NOT NULL, + "sent_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "blacklist" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "phone" text NOT NULL, + "added_by_id" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "client_leads" ( + "client_id" integer NOT NULL, + "lead_id" integer NOT NULL, + "state" text DEFAULT 'offered' NOT NULL, + "shown_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clients" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "name" text NOT NULL, + "phone" text, + "kind" text NOT NULL, + "request" jsonb DEFAULT '{}'::jsonb NOT NULL, + "source" text, + "assignee_id" integer NOT NULL, + "status_id" integer NOT NULL, + "reject_reason_id" integer, + "next_action_at" timestamp with time zone, + "next_action_note" text, + "next_action_notified_at" timestamp with time zone, + "status_changed_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "companies" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "plan" text NOT NULL, + "billing_model" text DEFAULT 'fixed' NOT NULL, + "planned_plan" text, + "planned_billing_model" text, + "planned_from" date, + "subscription_ends_at" timestamp with time zone, + "grace_days" integer DEFAULT 15 NOT NULL, + "access_enabled" boolean DEFAULT false NOT NULL, + "setup_fee_invoiced" boolean DEFAULT false NOT NULL, + "settings" jsonb NOT NULL, + "last_activity_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "deals" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "lead_id" integer, + "client_id" integer, + "agent_id" integer NOT NULL, + "commission" numeric(14, 2) NOT NULL, + "currency" text NOT NULL, + "deal_date" date NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "email_tokens" ( + "token_hash" text PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "kind" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "events" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "lead_id" integer, + "client_id" integer, + "user_id" integer, + "type" text NOT NULL, + "old_value" text, + "new_value" text, + "text" text, + "at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "filters" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "owner_user_id" integer, + "name" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "params" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invites" ( + "token" text PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "role" text DEFAULT 'agent' NOT NULL, + "created_by_id" integer NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoices" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "kind" text NOT NULL, + "period_start" date, + "period_end" date, + "amount" numeric(14, 2) NOT NULL, + "currency" text DEFAULT 'USD' NOT NULL, + "details" jsonb, + "status" text DEFAULT 'pending' NOT NULL, + "paid_at" timestamp with time zone, + "paid_method" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "lead_notifications" ( + "id" serial PRIMARY KEY NOT NULL, + "lead_id" integer NOT NULL, + "user_id" integer NOT NULL, + "chat_id" bigint, + "message_id" integer, + "has_photo" boolean DEFAULT false NOT NULL, + "sent_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "leads" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "listing_id" integer, + "source" text NOT NULL, + "url" text, + "title" text NOT NULL, + "description" text, + "deal_type" text, + "property_type" text, + "region" text, + "district" text, + "address" text, + "price" numeric(14, 2), + "currency" text, + "rooms" integer, + "area" numeric(10, 2), + "floor" integer, + "floors_total" integer, + "photos" jsonb DEFAULT '[]'::jsonb NOT NULL, + "author_name" text, + "phone" text, + "assignee_id" integer, + "status_id" integer NOT NULL, + "reject_reason_id" integer, + "next_action_at" timestamp with time zone, + "next_action_note" text, + "next_action_notified_at" timestamp with time zone, + "taken_at" timestamp with time zone, + "status_changed_at" timestamp with time zone DEFAULT now() NOT NULL, + "pool_warned_at" timestamp with time zone, + "previous_lead_id" integer, + "created_by_id" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "listings" ( + "id" serial PRIMARY KEY NOT NULL, + "external_id" text NOT NULL, + "url" text NOT NULL, + "title" text NOT NULL, + "description" text, + "deal_type" text, + "property_type" text, + "region" text, + "district" text, + "address" text, + "price" numeric(14, 2), + "currency" text, + "rooms" integer, + "area" numeric(10, 2), + "floor" integer, + "floors_total" integer, + "photos" jsonb DEFAULT '[]'::jsonb NOT NULL, + "author_name" text, + "phone" text, + "is_agency" boolean DEFAULT false NOT NULL, + "published_at" timestamp with time zone, + "active" boolean DEFAULT true NOT NULL, + "removed_at" timestamp with time zone, + "last_checked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "outbox" ( + "id" serial PRIMARY KEY NOT NULL, + "kind" text NOT NULL, + "user_id" integer NOT NULL, + "lead_id" integer, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "run_at" timestamp with time zone DEFAULT now() NOT NULL, + "done_at" timestamp with time zone, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "parser_runs" ( + "id" serial PRIMARY KEY NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "status" text DEFAULT 'running' NOT NULL, + "found_new" integer DEFAULT 0 NOT NULL, + "errors" text +); +--> statement-breakpoint +CREATE TABLE "parser_state" ( + "category_id" integer PRIMARY KEY NOT NULL, + "baseline_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payments" ( + "id" serial PRIMARY KEY NOT NULL, + "invoice_id" integer NOT NULL, + "provider" text NOT NULL, + "external_id" text, + "amount" numeric(14, 2) NOT NULL, + "currency" text NOT NULL, + "status" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pipeline_statuses" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "pipeline" text NOT NULL, + "code" text, + "name" text NOT NULL, + "position" integer NOT NULL, + "pool_return" boolean DEFAULT false NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "reject_reasons" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "pipeline" text NOT NULL, + "code" text, + "name" text NOT NULL, + "position" integer NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "seen_listings" ( + "external_id" text PRIMARY KEY NOT NULL, + "seen_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "token_hash" text PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "acting_company_id" integer, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer, + "role" text NOT NULL, + "name" text NOT NULL, + "telegram_id" bigint, + "telegram_username" text, + "email" text, + "email_verified_at" timestamp with time zone, + "password_hash" text, + "lang" text DEFAULT 'ru' NOT NULL, + "quiet_from" text, + "quiet_to" text, + "notifications_paused" boolean DEFAULT false NOT NULL, + "telegram_link_token" text, + "removed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "admin_messages" ADD CONSTRAINT "admin_messages_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "admin_messages" ADD CONSTRAINT "admin_messages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blacklist" ADD CONSTRAINT "blacklist_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blacklist" ADD CONSTRAINT "blacklist_added_by_id_users_id_fk" FOREIGN KEY ("added_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_leads" ADD CONSTRAINT "client_leads_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_leads" ADD CONSTRAINT "client_leads_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clients" ADD CONSTRAINT "clients_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clients" ADD CONSTRAINT "clients_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clients" ADD CONSTRAINT "clients_status_id_pipeline_statuses_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."pipeline_statuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "clients" ADD CONSTRAINT "clients_reject_reason_id_reject_reasons_id_fk" FOREIGN KEY ("reject_reason_id") REFERENCES "public"."reject_reasons"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_agent_id_users_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "email_tokens" ADD CONSTRAINT "email_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "filters" ADD CONSTRAINT "filters_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "filters" ADD CONSTRAINT "filters_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_notifications" ADD CONSTRAINT "lead_notifications_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_notifications" ADD CONSTRAINT "lead_notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_listing_id_listings_id_fk" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_status_id_pipeline_statuses_id_fk" FOREIGN KEY ("status_id") REFERENCES "public"."pipeline_statuses"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_reject_reason_id_reject_reasons_id_fk" FOREIGN KEY ("reject_reason_id") REFERENCES "public"."reject_reasons"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "outbox" ADD CONSTRAINT "outbox_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "outbox" ADD CONSTRAINT "outbox_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pipeline_statuses" ADD CONSTRAINT "pipeline_statuses_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reject_reasons" ADD CONSTRAINT "reject_reasons_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_acting_company_id_companies_id_fk" FOREIGN KEY ("acting_company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "blacklist_uq" ON "blacklist" USING btree ("company_id","phone");--> statement-breakpoint +CREATE UNIQUE INDEX "client_leads_uq" ON "client_leads" USING btree ("client_id","lead_id");--> statement-breakpoint +CREATE INDEX "clients_company_idx" ON "clients" USING btree ("company_id","status_id");--> statement-breakpoint +CREATE INDEX "deals_company_date_idx" ON "deals" USING btree ("company_id","deal_date");--> statement-breakpoint +CREATE UNIQUE INDEX "deals_lead_uq" ON "deals" USING btree ("lead_id");--> statement-breakpoint +CREATE UNIQUE INDEX "deals_client_uq" ON "deals" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "events_lead_idx" ON "events" USING btree ("lead_id");--> statement-breakpoint +CREATE INDEX "events_client_idx" ON "events" USING btree ("client_id");--> statement-breakpoint +CREATE INDEX "filters_company_idx" ON "filters" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "invoices_company_idx" ON "invoices" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "lead_notifications_lead_idx" ON "lead_notifications" USING btree ("lead_id");--> statement-breakpoint +CREATE INDEX "leads_company_status_idx" ON "leads" USING btree ("company_id","status_id");--> statement-breakpoint +CREATE INDEX "leads_company_assignee_idx" ON "leads" USING btree ("company_id","assignee_id");--> statement-breakpoint +CREATE UNIQUE INDEX "leads_company_listing_uq" ON "leads" USING btree ("company_id","listing_id") WHERE "leads"."previous_lead_id" is null and "leads"."listing_id" is not null;--> statement-breakpoint +CREATE UNIQUE INDEX "listings_external_uq" ON "listings" USING btree ("external_id");--> statement-breakpoint +CREATE INDEX "listings_phone_idx" ON "listings" USING btree ("phone");--> statement-breakpoint +CREATE INDEX "outbox_due_idx" ON "outbox" USING btree ("run_at") WHERE "outbox"."done_at" is null;--> statement-breakpoint +CREATE INDEX "statuses_company_idx" ON "pipeline_statuses" USING btree ("company_id","pipeline");--> statement-breakpoint +CREATE UNIQUE INDEX "users_telegram_uq" ON "users" USING btree ("telegram_id");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_uq" ON "users" USING btree (lower("email"));--> statement-breakpoint +CREATE INDEX "users_company_idx" ON "users" USING btree ("company_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0001_left_lady_deathstrike.sql b/apps/api/drizzle/0001_left_lady_deathstrike.sql new file mode 100644 index 0000000..91252b7 --- /dev/null +++ b/apps/api/drizzle/0001_left_lady_deathstrike.sql @@ -0,0 +1,9 @@ +ALTER TABLE "events" ADD COLUMN "status_id" integer;--> statement-breakpoint +UPDATE "events" e SET "status_id" = s."id" +FROM "pipeline_statuses" s +WHERE e."type" IN ('status', 'deal') AND s."company_id" = e."company_id" AND s."name" = e."new_value" + AND s."pipeline" = CASE WHEN e."lead_id" IS NOT NULL THEN 'object' ELSE 'client' END; +--> statement-breakpoint +UPDATE "events" e SET "status_id" = s."id" +FROM "pipeline_statuses" s +WHERE e."type" = 'blacklisted' AND s."company_id" = e."company_id" AND s."pipeline" = 'object' AND s."code" = 'rejected'; diff --git a/apps/api/drizzle/0002_black_shocker.sql b/apps/api/drizzle/0002_black_shocker.sql new file mode 100644 index 0000000..0be7eb6 --- /dev/null +++ b/apps/api/drizzle/0002_black_shocker.sql @@ -0,0 +1,5 @@ +CREATE TABLE "settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/apps/api/drizzle/0003_sharp_giant_girl.sql b/apps/api/drizzle/0003_sharp_giant_girl.sql new file mode 100644 index 0000000..3428f38 --- /dev/null +++ b/apps/api/drizzle/0003_sharp_giant_girl.sql @@ -0,0 +1,8 @@ +ALTER TABLE "admin_messages" ADD COLUMN "unreachable" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "companies" ADD COLUMN "percent_from" date;--> statement-breakpoint +ALTER TABLE "companies" ADD COLUMN "billing_notice" text;--> statement-breakpoint +ALTER TABLE "sessions" ADD COLUMN "via_telegram" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "onboarded_at" timestamp with time zone;--> statement-breakpoint +-- Уже работающим пользователям мастер настройки не показывается. +UPDATE "users" SET "onboarded_at" = "created_at" WHERE "company_id" IS NOT NULL;--> statement-breakpoint +UPDATE "companies" SET "percent_from" = ("created_at" AT TIME ZONE 'Asia/Yerevan')::date WHERE "billing_model" = 'percent'; diff --git a/apps/api/drizzle/0004_eager_mockingbird.sql b/apps/api/drizzle/0004_eager_mockingbird.sql new file mode 100644 index 0000000..50b1908 --- /dev/null +++ b/apps/api/drizzle/0004_eager_mockingbird.sql @@ -0,0 +1,32 @@ +CREATE TABLE "support_messages" ( + "id" serial PRIMARY KEY NOT NULL, + "ticket_id" integer NOT NULL, + "from_support" boolean DEFAULT false NOT NULL, + "author_id" integer, + "text" text, + "file_id" text, + "file_kind" text, + "file_name" text, + "at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_tickets" ( + "id" serial PRIMARY KEY NOT NULL, + "chat_id" bigint NOT NULL, + "user_id" integer, + "tg_name" text, + "tg_username" text, + "status" text DEFAULT 'open' NOT NULL, + "unread" boolean DEFAULT true NOT NULL, + "last_message_at" timestamp with time zone DEFAULT now() NOT NULL, + "closed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "outbox" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "support_messages" ADD CONSTRAINT "support_messages_ticket_id_support_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."support_tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_messages" ADD CONSTRAINT "support_messages_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "support_messages_ticket_idx" ON "support_messages" USING btree ("ticket_id");--> statement-breakpoint +CREATE INDEX "support_tickets_status_idx" ON "support_tickets" USING btree ("status","last_message_at");--> statement-breakpoint +CREATE UNIQUE INDEX "support_tickets_open_uq" ON "support_tickets" USING btree ("chat_id") WHERE "support_tickets"."status" = 'open'; \ No newline at end of file diff --git a/apps/api/drizzle/0005_left_slapstick.sql b/apps/api/drizzle/0005_left_slapstick.sql new file mode 100644 index 0000000..7fa7f57 --- /dev/null +++ b/apps/api/drizzle/0005_left_slapstick.sql @@ -0,0 +1,18 @@ +CREATE TABLE "client_photos" ( + "id" serial PRIMARY KEY NOT NULL, + "client_id" integer NOT NULL, + "company_id" integer NOT NULL, + "content_type" text NOT NULL, + "size" integer NOT NULL, + "data" text NOT NULL, + "uploaded_by_id" integer, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "clients" ADD COLUMN "household" jsonb;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "features" jsonb;--> statement-breakpoint +ALTER TABLE "listings" ADD COLUMN "features" jsonb;--> statement-breakpoint +ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_photos" ADD CONSTRAINT "client_photos_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "client_photos_client_idx" ON "client_photos" USING btree ("client_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0006_thick_umar.sql b/apps/api/drizzle/0006_thick_umar.sql new file mode 100644 index 0000000..d4c64ff --- /dev/null +++ b/apps/api/drizzle/0006_thick_umar.sql @@ -0,0 +1,58 @@ +CREATE TABLE "client_pages" ( + "token" text PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "client_id" integer NOT NULL, + "created_by_id" integer NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "intake_links" ( + "token" text PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "intake_sessions" ( + "id" serial PRIMARY KEY NOT NULL, + "link_token" text NOT NULL, + "chat_id" bigint NOT NULL, + "tg_name" text, + "tg_username" text, + "lang" text DEFAULT 'ru' NOT NULL, + "step" text NOT NULL, + "answers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_id" integer, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "viewings" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "client_id" integer NOT NULL, + "lead_id" integer NOT NULL, + "day" date NOT NULL, + "note" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "companies" ADD COLUMN "slug" text;--> statement-breakpoint +ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "client_pages" ADD CONSTRAINT "client_pages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intake_links" ADD CONSTRAINT "intake_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intake_links" ADD CONSTRAINT "intake_links_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intake_sessions" ADD CONSTRAINT "intake_sessions_link_token_intake_links_token_fk" FOREIGN KEY ("link_token") REFERENCES "public"."intake_links"("token") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "intake_sessions" ADD CONSTRAINT "intake_sessions_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "viewings" ADD CONSTRAINT "viewings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "viewings" ADD CONSTRAINT "viewings_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "viewings" ADD CONSTRAINT "viewings_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "client_pages_client_uq" ON "client_pages" USING btree ("client_id");--> statement-breakpoint +CREATE UNIQUE INDEX "intake_links_user_uq" ON "intake_links" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "intake_sessions_chat_idx" ON "intake_sessions" USING btree ("chat_id");--> statement-breakpoint +CREATE UNIQUE INDEX "viewings_uq" ON "viewings" USING btree ("client_id","lead_id");--> statement-breakpoint +ALTER TABLE "companies" ADD CONSTRAINT "companies_slug_unique" UNIQUE("slug");--> statement-breakpoint +-- У уже созданных компаний адрес страниц — «c<номер>»; руководитель может поменять его в настройках. +UPDATE "companies" SET "slug" = 'c' || "id" WHERE "slug" IS NULL; diff --git a/apps/api/drizzle/0007_chilly_maddog.sql b/apps/api/drizzle/0007_chilly_maddog.sql new file mode 100644 index 0000000..d0cc083 --- /dev/null +++ b/apps/api/drizzle/0007_chilly_maddog.sql @@ -0,0 +1,3 @@ +ALTER TABLE "leads" ADD COLUMN "showcase_text" text;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "gallery_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "gallery_tries" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/apps/api/drizzle/0008_concerned_cardiac.sql b/apps/api/drizzle/0008_concerned_cardiac.sql new file mode 100644 index 0000000..5637811 --- /dev/null +++ b/apps/api/drizzle/0008_concerned_cardiac.sql @@ -0,0 +1 @@ +ALTER TABLE "leads" ADD COLUMN "gallery_wanted_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/0009_bizarre_randall_flagg.sql b/apps/api/drizzle/0009_bizarre_randall_flagg.sql new file mode 100644 index 0000000..ee3cc3d --- /dev/null +++ b/apps/api/drizzle/0009_bizarre_randall_flagg.sql @@ -0,0 +1,20 @@ +CREATE TABLE "listing_phones" ( + "id" serial PRIMARY KEY NOT NULL, + "listing_id" integer NOT NULL, + "company_id" integer NOT NULL, + "user_id" integer, + "phone" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_listing_id_listings_id_fk" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "listing_phones" ADD CONSTRAINT "listing_phones_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "listing_phones_uq" ON "listing_phones" USING btree ("listing_id","company_id");--> statement-breakpoint +CREATE INDEX "listing_phones_phone_idx" ON "listing_phones" USING btree ("listing_id","phone");--> statement-breakpoint +-- Номера, уже внесённые агентами, становятся вариантами в общей базе. +INSERT INTO "listing_phones" ("listing_id", "company_id", "phone") +SELECT DISTINCT ON (l."listing_id", l."company_id") l."listing_id", l."company_id", l."phone" +FROM "leads" l WHERE l."listing_id" IS NOT NULL AND l."phone" IS NOT NULL +ORDER BY l."listing_id", l."company_id", l."id" DESC +ON CONFLICT DO NOTHING; diff --git a/apps/api/drizzle/0010_wakeful_exodus.sql b/apps/api/drizzle/0010_wakeful_exodus.sql new file mode 100644 index 0000000..aad1328 --- /dev/null +++ b/apps/api/drizzle/0010_wakeful_exodus.sql @@ -0,0 +1,2 @@ +ALTER TABLE "clients" ADD COLUMN "commission" jsonb;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "commission" jsonb; \ No newline at end of file diff --git a/apps/api/drizzle/0011_soft_edwin_jarvis.sql b/apps/api/drizzle/0011_soft_edwin_jarvis.sql new file mode 100644 index 0000000..e0902c1 --- /dev/null +++ b/apps/api/drizzle/0011_soft_edwin_jarvis.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "pay" jsonb; \ No newline at end of file diff --git a/apps/api/drizzle/0012_organic_thing.sql b/apps/api/drizzle/0012_organic_thing.sql new file mode 100644 index 0000000..b282045 --- /dev/null +++ b/apps/api/drizzle/0012_organic_thing.sql @@ -0,0 +1,19 @@ +ALTER TABLE "deals" ADD COLUMN "owner_commission" numeric(14, 2) DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "deals" ADD COLUMN "client_commission" numeric(14, 2) DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE "deals" ADD COLUMN "owner_agent_id" integer;--> statement-breakpoint +ALTER TABLE "deals" ADD COLUMN "client_agent_id" integer;--> statement-breakpoint +ALTER TABLE "deals" ADD COLUMN "kind" text DEFAULT 'sale' NOT NULL;--> statement-breakpoint +-- Прежние сделки остаются целиком за тем, кто их закрыл: сумма — на стороне объекта, если он есть, иначе на стороне клиента. +UPDATE "deals" SET + "owner_agent_id" = "agent_id", + "client_agent_id" = "agent_id", + "owner_commission" = CASE WHEN "lead_id" IS NOT NULL THEN "commission" ELSE 0 END, + "client_commission" = CASE WHEN "lead_id" IS NOT NULL THEN 0 ELSE "commission" END;--> statement-breakpoint +UPDATE "deals" d SET "kind" = 'rent' + WHERE EXISTS (SELECT 1 FROM "leads" l WHERE l."id" = d."lead_id" AND l."deal_type" = 'rent') + OR (NOT EXISTS (SELECT 1 FROM "leads" l WHERE l."id" = d."lead_id" AND l."deal_type" IS NOT NULL) + AND EXISTS (SELECT 1 FROM "clients" c WHERE c."id" = d."client_id" AND c."kind" = 'tenant'));--> statement-breakpoint +ALTER TABLE "deals" ALTER COLUMN "owner_agent_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "deals" ALTER COLUMN "client_agent_id" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_owner_agent_id_users_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deals" ADD CONSTRAINT "deals_client_agent_id_users_id_fk" FOREIGN KEY ("client_agent_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; diff --git a/apps/api/drizzle/0013_shallow_kabuki.sql b/apps/api/drizzle/0013_shallow_kabuki.sql new file mode 100644 index 0000000..73b3a38 --- /dev/null +++ b/apps/api/drizzle/0013_shallow_kabuki.sql @@ -0,0 +1,35 @@ +CREATE TABLE "pay_adjustments" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "user_id" integer NOT NULL, + "month" text NOT NULL, + "amount" numeric(14, 2) NOT NULL, + "currency" text NOT NULL, + "note" text NOT NULL, + "created_by" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payouts" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "user_id" integer NOT NULL, + "month" text NOT NULL, + "amount" numeric(14, 2) NOT NULL, + "currency" text NOT NULL, + "paid_at" date NOT NULL, + "note" text, + "snapshot" jsonb NOT NULL, + "created_by" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pay_adjustments" ADD CONSTRAINT "pay_adjustments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payouts" ADD CONSTRAINT "payouts_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "pay_adjustments_user_month_idx" ON "pay_adjustments" USING btree ("company_id","user_id","month");--> statement-breakpoint +CREATE UNIQUE INDEX "payouts_user_month_uq" ON "payouts" USING btree ("user_id","month");--> statement-breakpoint +CREATE INDEX "payouts_company_idx" ON "payouts" USING btree ("company_id"); \ No newline at end of file diff --git a/apps/api/drizzle/0014_modern_gabe_jones.sql b/apps/api/drizzle/0014_modern_gabe_jones.sql new file mode 100644 index 0000000..634f632 --- /dev/null +++ b/apps/api/drizzle/0014_modern_gabe_jones.sql @@ -0,0 +1,5 @@ +CREATE TABLE "bot_langs" ( + "telegram_id" bigint PRIMARY KEY NOT NULL, + "lang" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/apps/api/drizzle/0015_supreme_clint_barton.sql b/apps/api/drizzle/0015_supreme_clint_barton.sql new file mode 100644 index 0000000..581236c --- /dev/null +++ b/apps/api/drizzle/0015_supreme_clint_barton.sql @@ -0,0 +1,3 @@ +ALTER TABLE "leads" ADD COLUMN "bumped_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "listings" ADD COLUMN "bumped_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "parser_state" ADD COLUMN "top_ids" jsonb; \ No newline at end of file diff --git a/apps/api/drizzle/0016_massive_maria_hill.sql b/apps/api/drizzle/0016_massive_maria_hill.sql new file mode 100644 index 0000000..dd61672 --- /dev/null +++ b/apps/api/drizzle/0016_massive_maria_hill.sql @@ -0,0 +1,2 @@ +ALTER TABLE "clients" ADD COLUMN "telegram" text;--> statement-breakpoint +ALTER TABLE "clients" ADD COLUMN "rent_term" text; \ No newline at end of file diff --git a/apps/api/drizzle/0017_open_gabe_jones.sql b/apps/api/drizzle/0017_open_gabe_jones.sql new file mode 100644 index 0000000..6fc621f --- /dev/null +++ b/apps/api/drizzle/0017_open_gabe_jones.sql @@ -0,0 +1,2 @@ +ALTER TABLE "leads" ADD COLUMN "group_posted_commission" jsonb;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "group_posted_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/drizzle/0018_elite_magdalene.sql b/apps/api/drizzle/0018_elite_magdalene.sql new file mode 100644 index 0000000..1b0377a --- /dev/null +++ b/apps/api/drizzle/0018_elite_magdalene.sql @@ -0,0 +1,37 @@ +CREATE TABLE "departments" ( + "id" serial PRIMARY KEY NOT NULL, + "company_id" integer NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "lead_favorites" ( + "user_id" integer NOT NULL, + "lead_id" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lead_favorites_user_id_lead_id_pk" PRIMARY KEY("user_id","lead_id") +); +--> statement-breakpoint +CREATE TABLE "lead_views" ( + "user_id" integer NOT NULL, + "lead_id" integer NOT NULL, + "viewed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "lead_views_user_id_lead_id_pk" PRIMARY KEY("user_id","lead_id") +); +--> statement-breakpoint +DROP INDEX "leads_company_listing_uq";--> statement-breakpoint +ALTER TABLE "clients" ADD COLUMN "department_id" integer;--> statement-breakpoint +ALTER TABLE "filters" ADD COLUMN "department_id" integer;--> statement-breakpoint +ALTER TABLE "leads" ADD COLUMN "department_id" integer;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "department_id" integer;--> statement-breakpoint +ALTER TABLE "departments" ADD CONSTRAINT "departments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_favorites" ADD CONSTRAINT "lead_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_favorites" ADD CONSTRAINT "lead_favorites_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_views" ADD CONSTRAINT "lead_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "lead_views" ADD CONSTRAINT "lead_views_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "departments_company_idx" ON "departments" USING btree ("company_id");--> statement-breakpoint +ALTER TABLE "clients" ADD CONSTRAINT "clients_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "filters" ADD CONSTRAINT "filters_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "leads" ADD CONSTRAINT "leads_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "leads_company_dept_listing_uq" ON "leads" USING btree ("company_id",coalesce("department_id", 0),"listing_id") WHERE "leads"."previous_lead_id" is null and "leads"."listing_id" is not null; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..f6ed862 --- /dev/null +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,2624 @@ +{ + "id": "6b962a0b-df56-4f8f-994c-c0d0452232bb", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..dee6488 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2630 @@ +{ + "id": "583ed7ba-ec99-4816-ad58-a518402313a7", + "prevId": "6b962a0b-df56-4f8f-994c-c0d0452232bb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..1fe808d --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2662 @@ +{ + "id": "49e24e82-3437-442a-b4c5-45e6e5afbc79", + "prevId": "583ed7ba-ec99-4816-ad58-a518402313a7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..a3123fa --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2694 @@ +{ + "id": "81c03271-3108-445e-9127-ed50a6feca27", + "prevId": "49e24e82-3437-442a-b4c5-45e6e5afbc79", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0004_snapshot.json b/apps/api/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..849d55e --- /dev/null +++ b/apps/api/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2935 @@ +{ + "id": "fc89bf2b-8de0-4c5a-bd72-87680fba7ff7", + "prevId": "81c03271-3108-445e-9127-ed50a6feca27", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0005_snapshot.json b/apps/api/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..5ee1367 --- /dev/null +++ b/apps/api/drizzle/meta/0005_snapshot.json @@ -0,0 +1,3071 @@ +{ + "id": "17c59045-a85a-4424-8c82-68ac265b0015", + "prevId": "fc89bf2b-8de0-4c5a-bd72-87680fba7ff7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0006_snapshot.json b/apps/api/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..990fc0a --- /dev/null +++ b/apps/api/drizzle/meta/0006_snapshot.json @@ -0,0 +1,3515 @@ +{ + "id": "40305177-26a3-4597-a48b-fb4559b771de", + "prevId": "17c59045-a85a-4424-8c82-68ac265b0015", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0007_snapshot.json b/apps/api/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..001d163 --- /dev/null +++ b/apps/api/drizzle/meta/0007_snapshot.json @@ -0,0 +1,3534 @@ +{ + "id": "39f81496-99e6-497a-9735-27c7fed6cf44", + "prevId": "40305177-26a3-4597-a48b-fb4559b771de", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0008_snapshot.json b/apps/api/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..3e66b63 --- /dev/null +++ b/apps/api/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3540 @@ +{ + "id": "6e5538c5-5a77-439b-864b-6148e861592e", + "prevId": "39f81496-99e6-497a-9735-27c7fed6cf44", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0009_snapshot.json b/apps/api/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..3f6a6a3 --- /dev/null +++ b/apps/api/drizzle/meta/0009_snapshot.json @@ -0,0 +1,3673 @@ +{ + "id": "99ed8dc6-7604-4156-998e-ff156e897465", + "prevId": "6e5538c5-5a77-439b-864b-6148e861592e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0010_snapshot.json b/apps/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..c836ce6 --- /dev/null +++ b/apps/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3685 @@ +{ + "id": "00ada26b-e324-4d8e-b94b-7d9d7e0fec7f", + "prevId": "99ed8dc6-7604-4156-998e-ff156e897465", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0011_snapshot.json b/apps/api/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..739d30a --- /dev/null +++ b/apps/api/drizzle/meta/0011_snapshot.json @@ -0,0 +1,3691 @@ +{ + "id": "8e5e3d10-9263-41ec-9e2b-18754510e4c0", + "prevId": "00ada26b-e324-4d8e-b94b-7d9d7e0fec7f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0012_snapshot.json b/apps/api/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..74bb5dd --- /dev/null +++ b/apps/api/drizzle/meta/0012_snapshot.json @@ -0,0 +1,3750 @@ +{ + "id": "c0aec44c-6d85-47d1-9941-b70aae105f76", + "prevId": "8e5e3d10-9263-41ec-9e2b-18754510e4c0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0013_snapshot.json b/apps/api/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..e2b677a --- /dev/null +++ b/apps/api/drizzle/meta/0013_snapshot.json @@ -0,0 +1,4043 @@ +{ + "id": "a556bf00-9a42-45b5-b76c-27d12de3ccb1", + "prevId": "c0aec44c-6d85-47d1-9941-b70aae105f76", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0014_snapshot.json b/apps/api/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..28669eb --- /dev/null +++ b/apps/api/drizzle/meta/0014_snapshot.json @@ -0,0 +1,4075 @@ +{ + "id": "b55e2f51-7573-4312-ae3c-135b809eae28", + "prevId": "a556bf00-9a42-45b5-b76c-27d12de3ccb1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_langs": { + "name": "bot_langs", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0015_snapshot.json b/apps/api/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..6531171 --- /dev/null +++ b/apps/api/drizzle/meta/0015_snapshot.json @@ -0,0 +1,4093 @@ +{ + "id": "33beceb8-1b35-469c-9619-0e1fdf0f9828", + "prevId": "b55e2f51-7573-4312-ae3c-135b809eae28", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_langs": { + "name": "bot_langs", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "top_ids": { + "name": "top_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0016_snapshot.json b/apps/api/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..16ef58c --- /dev/null +++ b/apps/api/drizzle/meta/0016_snapshot.json @@ -0,0 +1,4105 @@ +{ + "id": "1a9424b9-fca9-4c30-b632-1b2938c6fc75", + "prevId": "33beceb8-1b35-469c-9619-0e1fdf0f9828", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_langs": { + "name": "bot_langs", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram": { + "name": "telegram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_term": { + "name": "rent_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "top_ids": { + "name": "top_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0017_snapshot.json b/apps/api/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..d5230a9 --- /dev/null +++ b/apps/api/drizzle/meta/0017_snapshot.json @@ -0,0 +1,4117 @@ +{ + "id": "789ea4ba-6f20-4933-be2d-fc6a13137a7c", + "prevId": "1a9424b9-fca9-4c30-b632-1b2938c6fc75", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_langs": { + "name": "bot_langs", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram": { + "name": "telegram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_term": { + "name": "rent_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "group_posted_commission": { + "name": "group_posted_commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_posted_at": { + "name": "group_posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_listing_uq": { + "name": "leads_company_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "top_ids": { + "name": "top_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/0018_snapshot.json b/apps/api/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000..dd17ed4 --- /dev/null +++ b/apps/api/drizzle/meta/0018_snapshot.json @@ -0,0 +1,4401 @@ +{ + "id": "0e0e3d8c-d9c3-4712-b630-aae9d0a098f2", + "prevId": "789ea4ba-6f20-4933-be2d-fc6a13137a7c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_messages": { + "name": "admin_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "recipients": { + "name": "recipients", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unreachable": { + "name": "unreachable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_messages_company_id_companies_id_fk": { + "name": "admin_messages_company_id_companies_id_fk", + "tableFrom": "admin_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "admin_messages_user_id_users_id_fk": { + "name": "admin_messages_user_id_users_id_fk", + "tableFrom": "admin_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_id": { + "name": "added_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blacklist_uq": { + "name": "blacklist_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blacklist_company_id_companies_id_fk": { + "name": "blacklist_company_id_companies_id_fk", + "tableFrom": "blacklist", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "blacklist_added_by_id_users_id_fk": { + "name": "blacklist_added_by_id_users_id_fk", + "tableFrom": "blacklist", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_langs": { + "name": "bot_langs", + "schema": "", + "columns": { + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_leads": { + "name": "client_leads", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offered'" + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_leads_uq": { + "name": "client_leads_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_leads_client_id_clients_id_fk": { + "name": "client_leads_client_id_clients_id_fk", + "tableFrom": "client_leads", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_leads_lead_id_leads_id_fk": { + "name": "client_leads_lead_id_leads_id_fk", + "tableFrom": "client_leads", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pages": { + "name": "client_pages", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_pages_client_uq": { + "name": "client_pages_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_pages_company_id_companies_id_fk": { + "name": "client_pages_company_id_companies_id_fk", + "tableFrom": "client_pages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_pages_client_id_clients_id_fk": { + "name": "client_pages_client_id_clients_id_fk", + "tableFrom": "client_pages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_pages_created_by_id_users_id_fk": { + "name": "client_pages_created_by_id_users_id_fk", + "tableFrom": "client_pages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_photos": { + "name": "client_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_photos_client_idx": { + "name": "client_photos_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_photos_client_id_clients_id_fk": { + "name": "client_photos_client_id_clients_id_fk", + "tableFrom": "client_photos", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_photos_company_id_companies_id_fk": { + "name": "client_photos_company_id_companies_id_fk", + "tableFrom": "client_photos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_photos_uploaded_by_id_users_id_fk": { + "name": "client_photos_uploaded_by_id_users_id_fk", + "tableFrom": "client_photos", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telegram": { + "name": "telegram", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rent_term": { + "name": "rent_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "household": { + "name": "household", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "department_id": { + "name": "department_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "clients_company_idx": { + "name": "clients_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_company_id_companies_id_fk": { + "name": "clients_company_id_companies_id_fk", + "tableFrom": "clients", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_assignee_id_users_id_fk": { + "name": "clients_assignee_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_status_id_pipeline_statuses_id_fk": { + "name": "clients_status_id_pipeline_statuses_id_fk", + "tableFrom": "clients", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_reject_reason_id_reject_reasons_id_fk": { + "name": "clients_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "clients", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clients_department_id_departments_id_fk": { + "name": "clients_department_id_departments_id_fk", + "tableFrom": "clients", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_model": { + "name": "billing_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "planned_plan": { + "name": "planned_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_billing_model": { + "name": "planned_billing_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_from": { + "name": "planned_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "subscription_ends_at": { + "name": "subscription_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_days": { + "name": "grace_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "access_enabled": { + "name": "access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "setup_fee_invoiced": { + "name": "setup_fee_invoiced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "percent_from": { + "name": "percent_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "billing_notice": { + "name": "billing_notice", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_slug_unique": { + "name": "companies_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deals": { + "name": "deals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "commission": { + "name": "commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "owner_commission": { + "name": "owner_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_commission": { + "name": "client_commission", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_agent_id": { + "name": "client_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sale'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deal_date": { + "name": "deal_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deals_company_date_idx": { + "name": "deals_company_date_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deal_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_lead_uq": { + "name": "deals_lead_uq", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deals_client_uq": { + "name": "deals_client_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deals_company_id_companies_id_fk": { + "name": "deals_company_id_companies_id_fk", + "tableFrom": "deals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_lead_id_leads_id_fk": { + "name": "deals_lead_id_leads_id_fk", + "tableFrom": "deals", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_id_clients_id_fk": { + "name": "deals_client_id_clients_id_fk", + "tableFrom": "deals", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_agent_id_users_id_fk": { + "name": "deals_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_owner_agent_id_users_id_fk": { + "name": "deals_owner_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deals_client_agent_id_users_id_fk": { + "name": "deals_client_agent_id_users_id_fk", + "tableFrom": "deals", + "tableTo": "users", + "columnsFrom": [ + "client_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_company_idx": { + "name": "departments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_company_id_companies_id_fk": { + "name": "departments_company_id_companies_id_fk", + "tableFrom": "departments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_tokens": { + "name": "email_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "email_tokens_user_id_users_id_fk": { + "name": "email_tokens_user_id_users_id_fk", + "tableFrom": "email_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "old_value": { + "name": "old_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_lead_idx": { + "name": "events_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_client_idx": { + "name": "events_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_company_id_companies_id_fk": { + "name": "events_company_id_companies_id_fk", + "tableFrom": "events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_lead_id_leads_id_fk": { + "name": "events_lead_id_leads_id_fk", + "tableFrom": "events", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_client_id_clients_id_fk": { + "name": "events_client_id_clients_id_fk", + "tableFrom": "events", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.filters": { + "name": "filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "filters_company_idx": { + "name": "filters_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "filters_company_id_companies_id_fk": { + "name": "filters_company_id_companies_id_fk", + "tableFrom": "filters", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_owner_user_id_users_id_fk": { + "name": "filters_owner_user_id_users_id_fk", + "tableFrom": "filters", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "filters_department_id_departments_id_fk": { + "name": "filters_department_id_departments_id_fk", + "tableFrom": "filters", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_links": { + "name": "intake_links", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_links_user_uq": { + "name": "intake_links_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_links_company_id_companies_id_fk": { + "name": "intake_links_company_id_companies_id_fk", + "tableFrom": "intake_links", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_links_user_id_users_id_fk": { + "name": "intake_links_user_id_users_id_fk", + "tableFrom": "intake_links", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intake_sessions": { + "name": "intake_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link_token": { + "name": "link_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intake_sessions_chat_idx": { + "name": "intake_sessions_chat_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intake_sessions_link_token_intake_links_token_fk": { + "name": "intake_sessions_link_token_intake_links_token_fk", + "tableFrom": "intake_sessions", + "tableTo": "intake_links", + "columnsFrom": [ + "link_token" + ], + "columnsTo": [ + "token" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "intake_sessions_client_id_clients_id_fk": { + "name": "intake_sessions_client_id_clients_id_fk", + "tableFrom": "intake_sessions", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_method": { + "name": "paid_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_company_idx": { + "name": "invoices_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_company_id_companies_id_fk": { + "name": "invoices_company_id_companies_id_fk", + "tableFrom": "invoices", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_favorites": { + "name": "lead_favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lead_favorites_user_id_users_id_fk": { + "name": "lead_favorites_user_id_users_id_fk", + "tableFrom": "lead_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_favorites_lead_id_leads_id_fk": { + "name": "lead_favorites_lead_id_leads_id_fk", + "tableFrom": "lead_favorites", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lead_favorites_user_id_lead_id_pk": { + "name": "lead_favorites_user_id_lead_id_pk", + "columns": [ + "user_id", + "lead_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_notifications": { + "name": "lead_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_photo": { + "name": "has_photo", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "lead_notifications_lead_idx": { + "name": "lead_notifications_lead_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lead_notifications_lead_id_leads_id_fk": { + "name": "lead_notifications_lead_id_leads_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_notifications_user_id_users_id_fk": { + "name": "lead_notifications_user_id_users_id_fk", + "tableFrom": "lead_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_views": { + "name": "lead_views", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lead_views_user_id_users_id_fk": { + "name": "lead_views_user_id_users_id_fk", + "tableFrom": "lead_views", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lead_views_lead_id_leads_id_fk": { + "name": "lead_views_lead_id_leads_id_fk", + "tableFrom": "lead_views", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lead_views_user_id_lead_id_pk": { + "name": "lead_views_user_id_lead_id_pk", + "columns": [ + "user_id", + "lead_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.leads": { + "name": "leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reject_reason_id": { + "name": "reject_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_action_at": { + "name": "next_action_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action_note": { + "name": "next_action_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_action_notified_at": { + "name": "next_action_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status_changed_at": { + "name": "status_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pool_warned_at": { + "name": "pool_warned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commission": { + "name": "commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "showcase_text": { + "name": "showcase_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gallery_at": { + "name": "gallery_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "gallery_tries": { + "name": "gallery_tries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gallery_wanted_at": { + "name": "gallery_wanted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "previous_lead_id": { + "name": "previous_lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "group_posted_commission": { + "name": "group_posted_commission", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_posted_at": { + "name": "group_posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "leads_company_status_idx": { + "name": "leads_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_assignee_idx": { + "name": "leads_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leads_company_dept_listing_uq": { + "name": "leads_company_dept_listing_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"department_id\", 0)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"leads\".\"previous_lead_id\" is null and \"leads\".\"listing_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leads_company_id_companies_id_fk": { + "name": "leads_company_id_companies_id_fk", + "tableFrom": "leads", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_listing_id_listings_id_fk": { + "name": "leads_listing_id_listings_id_fk", + "tableFrom": "leads", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_assignee_id_users_id_fk": { + "name": "leads_assignee_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_status_id_pipeline_statuses_id_fk": { + "name": "leads_status_id_pipeline_statuses_id_fk", + "tableFrom": "leads", + "tableTo": "pipeline_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_reject_reason_id_reject_reasons_id_fk": { + "name": "leads_reject_reason_id_reject_reasons_id_fk", + "tableFrom": "leads", + "tableTo": "reject_reasons", + "columnsFrom": [ + "reject_reason_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_created_by_id_users_id_fk": { + "name": "leads_created_by_id_users_id_fk", + "tableFrom": "leads", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leads_department_id_departments_id_fk": { + "name": "leads_department_id_departments_id_fk", + "tableFrom": "leads", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listing_phones": { + "name": "listing_phones", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "listing_id": { + "name": "listing_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listing_phones_uq": { + "name": "listing_phones_uq", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listing_phones_phone_idx": { + "name": "listing_phones_phone_idx", + "columns": [ + { + "expression": "listing_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "listing_phones_listing_id_listings_id_fk": { + "name": "listing_phones_listing_id_listings_id_fk", + "tableFrom": "listing_phones", + "tableTo": "listings", + "columnsFrom": [ + "listing_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "listing_phones_company_id_companies_id_fk": { + "name": "listing_phones_company_id_companies_id_fk", + "tableFrom": "listing_phones", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "listing_phones_user_id_users_id_fk": { + "name": "listing_phones_user_id_users_id_fk", + "tableFrom": "listing_phones", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.listings": { + "name": "listings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deal_type": { + "name": "deal_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rooms": { + "name": "rooms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "floor": { + "name": "floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "floors_total": { + "name": "floors_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_agency": { + "name": "is_agency", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bumped_at": { + "name": "bumped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "listings_external_uq": { + "name": "listings_external_uq", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "listings_phone_idx": { + "name": "listings_phone_idx", + "columns": [ + { + "expression": "phone", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox": { + "name": "outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_due_idx": { + "name": "outbox_due_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox\".\"done_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "outbox_user_id_users_id_fk": { + "name": "outbox_user_id_users_id_fk", + "tableFrom": "outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "outbox_lead_id_leads_id_fk": { + "name": "outbox_lead_id_leads_id_fk", + "tableFrom": "outbox", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_runs": { + "name": "parser_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "found_new": { + "name": "found_new", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parser_state": { + "name": "parser_state", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "baseline_at": { + "name": "baseline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "top_ids": { + "name": "top_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_adjustments": { + "name": "pay_adjustments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pay_adjustments_user_month_idx": { + "name": "pay_adjustments_user_month_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pay_adjustments_company_id_companies_id_fk": { + "name": "pay_adjustments_company_id_companies_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_user_id_users_id_fk": { + "name": "pay_adjustments_user_id_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pay_adjustments_created_by_users_id_fk": { + "name": "pay_adjustments_created_by_users_id_fk", + "tableFrom": "pay_adjustments", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payouts": { + "name": "payouts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payouts_user_month_uq": { + "name": "payouts_user_month_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payouts_company_idx": { + "name": "payouts_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payouts_company_id_companies_id_fk": { + "name": "payouts_company_id_companies_id_fk", + "tableFrom": "payouts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_user_id_users_id_fk": { + "name": "payouts_user_id_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payouts_created_by_users_id_fk": { + "name": "payouts_created_by_users_id_fk", + "tableFrom": "payouts", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_statuses": { + "name": "pipeline_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pool_return": { + "name": "pool_return", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "statuses_company_idx": { + "name": "statuses_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_statuses_company_id_companies_id_fk": { + "name": "pipeline_statuses_company_id_companies_id_fk", + "tableFrom": "pipeline_statuses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reject_reasons": { + "name": "reject_reasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pipeline": { + "name": "pipeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "reject_reasons_company_id_companies_id_fk": { + "name": "reject_reasons_company_id_companies_id_fk", + "tableFrom": "reject_reasons", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seen_listings": { + "name": "seen_listings", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "acting_company_id": { + "name": "acting_company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "via_telegram": { + "name": "via_telegram", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sessions_acting_company_id_companies_id_fk": { + "name": "sessions_acting_company_id_companies_id_fk", + "tableFrom": "sessions", + "tableTo": "companies", + "columnsFrom": [ + "acting_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ticket_id": { + "name": "ticket_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_support": { + "name": "from_support", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_kind": { + "name": "file_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_ticket_idx": { + "name": "support_messages_ticket_idx", + "columns": [ + { + "expression": "ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_ticket_id_support_tickets_id_fk": { + "name": "support_messages_ticket_id_support_tickets_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "support_messages_author_id_users_id_fk": { + "name": "support_messages_author_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_tickets": { + "name": "support_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tg_name": { + "name": "tg_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tg_username": { + "name": "tg_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "unread": { + "name": "unread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_tickets_status_idx": { + "name": "support_tickets_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_tickets_open_uq": { + "name": "support_tickets_open_uq", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"support_tickets\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_tickets_user_id_users_id_fk": { + "name": "support_tickets_user_id_users_id_fk", + "tableFrom": "support_tickets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_id": { + "name": "telegram_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lang": { + "name": "lang", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ru'" + }, + "quiet_from": { + "name": "quiet_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quiet_to": { + "name": "quiet_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notifications_paused": { + "name": "notifications_paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "telegram_link_token": { + "name": "telegram_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pay": { + "name": "pay", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_telegram_uq": { + "name": "users_telegram_uq", + "columns": [ + { + "expression": "telegram_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uq": { + "name": "users_email_uq", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_company_idx": { + "name": "users_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_company_id_companies_id_fk": { + "name": "users_company_id_companies_id_fk", + "tableFrom": "users", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_department_id_departments_id_fk": { + "name": "users_department_id_departments_id_fk", + "tableFrom": "users", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewings": { + "name": "viewings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "viewings_uq": { + "name": "viewings_uq", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "viewings_company_id_companies_id_fk": { + "name": "viewings_company_id_companies_id_fk", + "tableFrom": "viewings", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "viewings_client_id_clients_id_fk": { + "name": "viewings_client_id_clients_id_fk", + "tableFrom": "viewings", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewings_lead_id_leads_id_fk": { + "name": "viewings_lead_id_leads_id_fk", + "tableFrom": "viewings", + "tableTo": "leads", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..106e289 --- /dev/null +++ b/apps/api/drizzle/meta/_journal.json @@ -0,0 +1,139 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791474709969, + "tag": "0000_busy_kat_farrell", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791486109720, + "tag": "0001_left_lady_deathstrike", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791487573067, + "tag": "0002_black_shocker", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1791538527629, + "tag": "0003_sharp_giant_girl", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1791542601013, + "tag": "0004_eager_mockingbird", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791548601291, + "tag": "0005_left_slapstick", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1791550647233, + "tag": "0006_thick_umar", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1791552698846, + "tag": "0007_chilly_maddog", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1791553975016, + "tag": "0008_concerned_cardiac", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1791555219811, + "tag": "0009_bizarre_randall_flagg", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1791555417590, + "tag": "0010_wakeful_exodus", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1791558446545, + "tag": "0011_soft_edwin_jarvis", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1791913393916, + "tag": "0012_organic_thing", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1791915450918, + "tag": "0013_shallow_kabuki", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1791918705929, + "tag": "0014_modern_gabe_jones", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1792148447759, + "tag": "0015_supreme_clint_barton", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1792444245607, + "tag": "0016_massive_maria_hill", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1792445277037, + "tag": "0017_open_gabe_jones", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1792954204010, + "tag": "0018_elite_magdalene", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..8be41f2 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lr/api", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch --env-file=../../.env src/main.ts", + "start": "tsx --env-file=../../.env src/main.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx --env-file=../../.env src/db/migrate.ts", + "superadmin": "tsx --env-file=../../.env src/cli/create-superadmin.ts" + }, + "dependencies": { + "@fastify/cookie": "^11.1.3", + "@lr/shared": "workspace:*", + "drizzle-orm": "^0.45.4", + "fastify": "^5.12.5", + "postgres": "^3.4.9", + "sharp": "^0.35.5", + "zod": "^4.6.5" + }, + "devDependencies": { + "drizzle-kit": "^0.31.11" + } +} diff --git a/apps/api/src/admin-listings.ts b/apps/api/src/admin-listings.ts new file mode 100644 index 0000000..5e786b5 --- /dev/null +++ b/apps/api/src/admin-listings.ts @@ -0,0 +1,93 @@ +import { DEAL_TYPES, PROPERTY_TYPES } from '@lr/shared'; +import { and, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from './db/client.ts'; +import { companies, leads, listings, pipelineStatuses, users } from './db/schema.ts'; +import { notFound } from './lib/errors.ts'; + +const PAGE = 50; + +/** Суперадмин: общая база объявлений сервиса — всё, что парсер собрал с list.am. */ +export function registerAdminListingRoutes(app: FastifyInstance, db: Db, superadmin: (req: FastifyRequest) => unknown) { + app.get('/api/admin/listings', async (req) => { + superadmin(req); + const q = z + .object({ + q: z.string().trim().max(100).optional(), + dealType: z.enum(DEAL_TYPES).optional(), + propertyType: z.enum(PROPERTY_TYPES).optional(), + region: z.string().trim().max(60).optional(), + /** active — на сайте, removed — сняты. */ + state: z.enum(['active', 'removed']).optional(), + /** yes — по объявлению есть лид хотя бы у одной компании, no — ни у кого. */ + leads: z.enum(['yes', 'no']).optional(), + author: z.enum(['owner', 'agency']).optional(), + priceFrom: z.coerce.number().nonnegative().optional(), + priceTo: z.coerce.number().nonnegative().optional(), + page: z.coerce.number().int().min(1).default(1), + }) + .parse(req.query); + const leadCount = sql`(select count(*)::int from leads l where l.listing_id = "listings"."id")`; + const where: (SQL | undefined)[] = []; + if (q.dealType) where.push(eq(listings.dealType, q.dealType)); + if (q.propertyType) where.push(eq(listings.propertyType, q.propertyType)); + if (q.region) where.push(eq(listings.region, q.region)); + if (q.state) where.push(eq(listings.active, q.state === 'active')); + if (q.author) where.push(eq(listings.isAgency, q.author === 'agency')); + if (q.priceFrom !== undefined) where.push(gte(listings.price, q.priceFrom)); + if (q.priceTo !== undefined) where.push(lte(listings.price, q.priceTo)); + if (q.leads) where.push(sql`${q.leads === 'yes' ? sql`` : sql`not `}exists (select 1 from leads l where l.listing_id = "listings"."id")`); + if (q.q) { + // Поиск по номеру объявления, названию, адресу, району и телефону. + const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`; + const digits = q.q.replace(/\D/g, ''); + where.push( + or( + eq(listings.externalId, q.q), + ilike(listings.title, like), + ilike(listings.address, like), + ilike(listings.district, like), + digits.length >= 4 ? ilike(listings.phone, `%${digits}%`) : undefined, + ), + ); + } + const [[total], rows] = await Promise.all([ + db.select({ n: sql`count(*)::int` }).from(listings).where(and(...where)), + db + .select({ listing: listings, leads: leadCount }) + .from(listings) + .where(and(...where)) + .orderBy(desc(sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt})`), desc(listings.id)) + .limit(PAGE) + .offset((q.page - 1) * PAGE), + ]); + return { + total: total?.n ?? 0, + pageSize: PAGE, + items: rows.map(({ listing: l, leads: n }) => ({ + id: l.id, externalId: l.externalId, url: l.url, title: l.title, dealType: l.dealType, propertyType: l.propertyType, + region: l.region, district: l.district, address: l.address, price: l.price, currency: l.currency, rooms: l.rooms, + area: l.area, floor: l.floor, floorsTotal: l.floorsTotal, photo: l.photos[0] ?? null, isAgency: l.isAgency, + phone: l.phone, publishedAt: l.publishedAt, bumpedAt: l.bumpedAt, createdAt: l.createdAt, active: l.active, removedAt: l.removedAt, leads: n, + })), + }; + }); + + /** Объявление целиком и то, каким компаниям оно ушло лидом. */ + app.get('/api/admin/listings/:id', async (req) => { + superadmin(req); + const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(req.params); + const [listing] = await db.select().from(listings).where(eq(listings.id, id)); + if (!listing) throw notFound(); + const sent = await db + .select({ id: leads.id, company: companies.name, status: pipelineStatuses.name, assignee: users.name, createdAt: leads.createdAt }) + .from(leads) + .innerJoin(companies, eq(companies.id, leads.companyId)) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .where(eq(leads.listingId, id)) + .orderBy(desc(leads.id)); + return { ...listing, leads: sent }; + }); +} diff --git a/apps/api/src/analytics/routes.ts b/apps/api/src/analytics/routes.ts new file mode 100644 index 0000000..f14c48c --- /dev/null +++ b/apps/api/src/analytics/routes.ts @@ -0,0 +1,20 @@ +import { hasModule, PIPELINES, type Plan } from '@lr/shared'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import type { Guards } from '../leads/routes.ts'; +import { badRequest, forbidden } from '../lib/errors.ts'; +import { buildAnalytics } from './service.ts'; + +export function registerAnalyticsRoutes(app: FastifyInstance, db: Db, g: Guards) { + /** Аналитика по сотрудникам: только руководителю и только в тарифах Team и Agency. */ + app.get('/api/analytics', async (req) => { + const ctx = g.manager(req); + if (!hasModule(ctx.company.plan as Plan, 'analytics')) throw forbidden('MODULE_NOT_AVAILABLE'); + const q = z + .object({ pipeline: z.enum(PIPELINES).default('object'), from: z.coerce.date(), to: z.coerce.date() }) + .parse(req.query); + if (q.from > q.to) throw badRequest('VALIDATION'); + return buildAnalytics(db, ctx.company.id, q.pipeline, q.from, q.to); + }); +} diff --git a/apps/api/src/analytics/service.ts b/apps/api/src/analytics/service.ts new file mode 100644 index 0000000..5075931 --- /dev/null +++ b/apps/api/src/analytics/service.ts @@ -0,0 +1,281 @@ +import type { Pipeline } from '@lr/shared'; +import { and, asc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { clientLeads, clients, deals, events, leads, pipelineStatuses, rejectReasons, users } from '../db/schema.ts'; + +// Аналитика по сотрудникам (ТЗ, раздел 6). Считается отдельно по воронке объектов и по воронке +// клиентов. Конверсия по этапам — когортная: берутся карточки, которые агент получил за период, +// и для каждого этапа считается, сколько из них до него дошли (на сегодня). + +export interface AgentRow { + userId: number; + name: string; + /** Объекты: нажатия «Беру» и назначения. Клиенты: новые запросы. */ + taken: number; + /** Медиана времени от уведомления до «Беру», секунды; null — нет данных. Только объекты. */ + reactionSec: number | null; + /** Лиды, потерянные по правилу возврата в пул. Только объекты. */ + poolReturns: number; + /** Показы объектов клиентам. Только клиенты. */ + showings: number; + /** Карточек в когорте — знаменатель конверсии. */ + cohort: number; + /** Сколько карточек когорты дошли до статуса: statusId → число. */ + reached: Record; + deals: number; + /** Сумма комиссий по валютам. */ + commission: Record; + /** Отказы по причинам: reasonId → число. */ + reasons: Record; +} + +export interface Analytics { + pipeline: Pipeline; + statuses: { id: number; code: string | null; name: string }[]; + reasons: { id: number; name: string }[]; + /** Сводка по компании: пришло (создано) за период, взято, сейчас в пуле (у клиентов — в работе). */ + summary: { created: number; taken: number; open: number; funnel: Record; cohort: number }; + agents: AgentRow[]; + totals: AgentRow; +} + +const median = (values: number[]) => { + if (!values.length) return null; + const s = [...values].sort((a, b) => a - b); + const mid = s.length >> 1; + return Math.round(s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2); +}; + +const emptyRow = (userId: number, name: string): AgentRow => ({ + userId, name, taken: 0, reactionSec: null, poolReturns: 0, showings: 0, cohort: 0, reached: {}, deals: 0, commission: {}, reasons: {}, +}); + +const bump = (map: Record, key: string | number, by = 1) => { + map[key] = (map[key] ?? 0) + by; +}; + +/** Дата по Еревану (UTC+4) для сравнения с датой сделки. */ +const yerevanDate = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10); + +export async function buildAnalytics(db: Db, companyId: number, pipeline: Pipeline, from: Date, to: Date): Promise { + const [statuses, reasons, members] = await Promise.all([ + db + .select() + .from(pipelineStatuses) + .where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt))) + .orderBy(asc(pipelineStatuses.position)), + // Удалённые причины остаются в отчёте: по ним могли быть отказы. + db.select().from(rejectReasons).where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline))).orderBy(asc(rejectReasons.position)), + db.select({ id: users.id, name: users.name, removedAt: users.removedAt }).from(users).where(eq(users.companyId, companyId)).orderBy(asc(users.id)), + ]); + const position = new Map(statuses.map((s, i) => [s.id, i])); + const rejectedId = statuses.find((s) => s.code === 'rejected')?.id; + const takenId = statuses.find((s) => s.code === 'taken')?.id; + const rows = new Map(members.map((m) => [m.id, emptyRow(m.id, m.name)])); + const row = (userId: number) => rows.get(userId) ?? rows.set(userId, emptyRow(userId, `#${userId}`)).get(userId)!; + const summary = { created: 0, taken: 0, open: 0, funnel: {} as Record, cohort: 0 }; + + /** + * Карточка «дошла до этапа», если побывала в нём или в любом более позднем рабочем этапе. + * «Отказ» считается отдельно: до него доходят из любого этапа. + */ + const countReached = (target: Record, visited: Set) => { + let max = -1; + for (const id of visited) if (id !== rejectedId) max = Math.max(max, position.get(id) ?? -1); + for (const s of statuses) { + if (s.id === rejectedId ? visited.has(s.id) : position.get(s.id)! <= max) bump(target, s.id); + } + }; + + const inPeriod = (column: Parameters[0]) => and(gte(column, from), lte(column, to)); + let allReactions: number[] = []; + const fromDay = yerevanDate(from); + const toDay = yerevanDate(to); + + if (pipeline === 'object') { + // Кто и когда получил лид за период. + const takes = await db + .select() + .from(events) + .where(and(eq(events.companyId, companyId), isNotNull(events.leadId), inArray(events.type, ['taken', 'assigned']), inPeriod(events.at))); + const [createdRows, manual] = await Promise.all([ + db.select({ id: leads.id, statusId: leads.statusId }).from(leads).where(and(eq(leads.companyId, companyId), inPeriod(leads.createdAt))), + db.select().from(events).where(and(eq(events.companyId, companyId), isNotNull(events.leadId), eq(events.type, 'created'), eq(events.newValue, 'manual'), inPeriod(events.at))), + ]); + const owner = (e: typeof events.$inferSelect) => (e.type === 'assigned' ? Number(e.newValue) : e.userId); + for (const e of takes) if (owner(e)) row(owner(e)!).taken++; + summary.created = createdRows.length; + summary.taken = new Set(takes.map((e) => e.leadId)).size; + + // История когортных лидов: по ней восстанавливаем, при каком агенте лид дошёл до этапа. + const cohortIds = [...new Set([...takes, ...manual].map((e) => e.leadId!).concat(createdRows.map((l) => l.id)))]; + const history = cohortIds.length + ? await db.select().from(events).where(inArray(events.leadId, cohortIds)).orderBy(asc(events.at), asc(events.id)) + : []; + const starts = new Set([...takes, ...manual].map((e) => e.id)); + /** leadId → (агент → этапы, пройденные при нём); в когорту агента лид входит, если получен за период. */ + const perLead = new Map; byAgent: Map; inCohort: boolean }> }>(); + const current = new Map(); + for (const e of history) { + const lead = perLead.get(e.leadId!) ?? perLead.set(e.leadId!, { all: new Set(), byAgent: new Map() }).get(e.leadId!)!; + const seg = (userId: number) => lead.byAgent.get(userId) ?? lead.byAgent.set(userId, { visited: new Set(), inCohort: false }).get(userId)!; + const visit = (statusId: number | null | undefined) => { + if (!statusId) return; + lead.all.add(statusId); + const who = current.get(e.leadId!); + if (who) seg(who).visited.add(statusId); + }; + if (e.type === 'taken' || e.type === 'assigned' || (e.type === 'created' && e.newValue === 'manual')) { + const who = owner(e); + if (!who) continue; + current.set(e.leadId!, who); + if (starts.has(e.id)) seg(who).inCohort = true; + visit(takenId); + } else if (e.type === 'pool_return') { + current.set(e.leadId!, null); + } else if (e.type === 'blacklisted') { + // «Это агент» из пула закрепляет лид за нажавшим. + if (!current.get(e.leadId!) && e.userId) current.set(e.leadId!, e.userId); + visit(e.statusId ?? rejectedId); + } else if (e.type === 'status' || e.type === 'deal') { + visit(e.statusId); + } + } + for (const lead of perLead.values()) { + for (const [userId, seg] of lead.byAgent) { + if (!seg.inCohort) continue; + const r = row(userId); + r.cohort++; + countReached(r.reached, seg.visited); + } + } + // Воронка целиком — по лидам, пришедшим за период; «Новый» прошли все. + const newId = statuses.find((s) => s.code === 'new')?.id; + for (const l of createdRows) { + const visited = new Set(perLead.get(l.id)?.all ?? []); + visited.add(l.statusId); + if (newId) visited.add(newId); + countReached(summary.funnel, visited); + } + summary.cohort = createdRows.length; + + const [pool, reactions, returns, rejected] = await Promise.all([ + db.select({ n: sql`count(*)::int` }).from(leads).where(and(eq(leads.companyId, companyId), isNull(leads.assigneeId), newId ? eq(leads.statusId, newId) : undefined)), + db + .select({ + userId: events.userId, + // Имена колонок внешнего запроса указаны с таблицей: иначе внутри подзапроса они относятся к n. + sec: sql`extract(epoch from "events"."at" - (select max(n.sent_at) from lead_notifications n where n.lead_id = "events"."lead_id" and n.user_id = "events"."user_id" and n.sent_at <= "events"."at"))::float`, + }) + .from(events) + .where(and(eq(events.companyId, companyId), eq(events.type, 'taken'), isNotNull(events.leadId), inPeriod(events.at))), + db.select().from(events).where(and(eq(events.companyId, companyId), eq(events.type, 'pool_return'), isNull(events.userId), isNotNull(events.oldValue), inPeriod(events.at))), + rejectedId + ? db + .select({ userId: leads.assigneeId, reasonId: leads.rejectReasonId }) + .from(leads) + .where(and(eq(leads.companyId, companyId), eq(leads.statusId, rejectedId), inPeriod(leads.statusChangedAt))) + : [], + ]); + summary.open = pool[0]?.n ?? 0; + const bySpeed = new Map(); + for (const r of reactions) if (r.userId && r.sec != null) bySpeed.set(r.userId, [...(bySpeed.get(r.userId) ?? []), r.sec]); + for (const [userId, list] of bySpeed) row(userId).reactionSec = median(list); + for (const e of returns) row(Number(e.oldValue)).poolReturns++; + for (const r of rejected) if (r.userId && r.reasonId) bump(row(r.userId).reasons, r.reasonId); + allReactions = reactions.flatMap((r) => (r.sec != null ? [r.sec] : [])); + } else { + const created = await db + .select({ id: clients.id, statusId: clients.statusId, assigneeId: clients.assigneeId }) + .from(clients) + .where(and(eq(clients.companyId, companyId), inPeriod(clients.createdAt))); + const ids = created.map((c) => c.id); + const history = ids.length + ? await db.select({ clientId: events.clientId, statusId: events.statusId }).from(events).where(and(inArray(events.clientId, ids), isNotNull(events.statusId))) + : []; + const newId = statuses.find((s) => s.code === 'new')?.id; + for (const c of created) { + const visited = new Set(history.filter((e) => e.clientId === c.id).map((e) => e.statusId!)); + visited.add(c.statusId); + if (newId) visited.add(newId); + const r = row(c.assigneeId); + r.taken++; + r.cohort++; + countReached(r.reached, visited); + countReached(summary.funnel, visited); + } + summary.created = summary.taken = summary.cohort = created.length; + const [open, shown, rejected] = await Promise.all([ + db + .select({ n: sql`count(*)::int` }) + .from(clients) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId)) + .where(and(eq(clients.companyId, companyId), sql`${pipelineStatuses.code} is distinct from 'deal' and ${pipelineStatuses.code} is distinct from 'rejected'`)), + db + .select({ userId: clients.assigneeId }) + .from(clientLeads) + .innerJoin(clients, eq(clients.id, clientLeads.clientId)) + .where(and(eq(clients.companyId, companyId), inPeriod(clientLeads.shownAt))), + rejectedId + ? db + .select({ userId: clients.assigneeId, reasonId: clients.rejectReasonId }) + .from(clients) + .where(and(eq(clients.companyId, companyId), eq(clients.statusId, rejectedId), inPeriod(clients.statusChangedAt))) + : [], + ]); + summary.open = open[0]?.n ?? 0; + for (const s of shown) row(s.userId).showings++; + for (const r of rejected) if (r.reasonId) bump(row(r.userId).reasons, r.reasonId); + } + + // Сделки за период — по дате сделки. Парная сделка одна, но видна в обеих воронках. + const closed = await db + .select() + .from(deals) + .where( + and( + eq(deals.companyId, companyId), + isNotNull(pipeline === 'object' ? deals.leadId : deals.clientId), + gte(deals.dealDate, fromDay), + lte(deals.dealDate, toDay), + ), + ); + // Каждому агенту — его часть комиссии: с собственника — агенту объекта, с клиента — агенту клиента. + for (const d of closed) { + for (const agentId of new Set([d.ownerAgentId, d.clientAgentId])) row(agentId).deals++; + if (d.ownerCommission) bump(row(d.ownerAgentId).commission, d.currency, d.ownerCommission); + if (d.clientCommission) bump(row(d.clientAgentId).commission, d.currency, d.clientCommission); + } + + // Суперадмин, работавший в кабинете компании, в её участниках не числится — имя берём отдельно. + const outside = [...rows.keys()].filter((id) => !members.some((m) => m.id === id)); + if (outside.length) { + for (const u of await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, outside))) rows.get(u.id)!.name = u.name; + } + const removed = new Set(members.filter((m) => m.removedAt).map((m) => m.id)); + const isEmpty = (r: AgentRow) => !r.taken && !r.cohort && !r.deals && !r.poolReturns && !r.showings && !Object.keys(r.reasons).length; + // Удалённые агенты остаются в отчёте, только если за период по ним есть данные. + const agents = [...rows.values()].filter((r) => !(isEmpty(r) && (removed.has(r.userId) || !members.some((m) => m.id === r.userId)))); + const totals = emptyRow(0, ''); + for (const r of agents) { + totals.taken += r.taken; + totals.poolReturns += r.poolReturns; + totals.showings += r.showings; + totals.cohort += r.cohort; + for (const [k, v] of Object.entries(r.reached)) bump(totals.reached, k, v); + for (const [k, v] of Object.entries(r.commission)) bump(totals.commission, k, v); + for (const [k, v] of Object.entries(r.reasons)) bump(totals.reasons, k, v); + } + // Сделку двух агентов видно у обоих, в итоге она одна. + totals.deals = closed.length; + totals.reactionSec = median(allReactions); + + return { + pipeline, + statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name })), + reasons: reasons.filter((r) => !r.deletedAt || agents.some((a) => a.reasons[r.id])).map((r) => ({ id: r.id, name: r.name })), + summary, + agents, + totals, + }; +} diff --git a/apps/api/src/app.int.test.ts b/apps/api/src/app.int.test.ts new file mode 100644 index 0000000..b932b09 --- /dev/null +++ b/apps/api/src/app.int.test.ts @@ -0,0 +1,253 @@ +import { createHash, createHmac } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { companies, pipelineStatuses, users } from './db/schema.ts'; +import { TEST_BOT_TOKEN, testApp } from './testing.ts'; + +let t: Awaited>; +beforeAll(async () => { t = await testApp(); }); +afterAll(() => t.close()); + +const superadmin = async () => { + const [u] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Date.now()}${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + return t.client(await createSession(t.db, u!.id)); +}; + +/** Компания, включённая суперадмином, и её руководитель. */ +const company = async (plan: 'solo' | 'team' | 'agency' = 'team') => { + const sa = await superadmin(); + const created = await sa.post('/api/admin/companies', { + name: `Агентство ${plan}`, plan, billingModel: 'fixed', + subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + expect(created.status).toBe(200); + const boss = await t.register('Руководитель'); + const token = (created.body.managerUrl as string).split('/invite/')[1]!; + expect((await boss.post(`/api/invites/${token}/accept`)).status).toBe(200); + return { sa, boss, id: created.body.id as number, managerToken: token }; +}; + +describe('свой домен компании', () => { + const HOST = 'arev.yandp.tech'; + const loginOn = async (host: string | undefined, email: string) => + t.client(undefined, host).post('/api/auth/login', { email, password: 'password-123' }); + + it('на домене компании входят только её сотрудники и суперадмин; на основном — все', async () => { + const own = await company(); + const other = await company(); + const [co] = await t.db.select().from(companies).where(eq(companies.id, own.id)); + await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: HOST } }).where(eq(companies.id, own.id)); + const ownEmail = (await own.boss.get('/api/me')).body.user.email as string; + const otherEmail = (await other.boss.get('/api/me')).body.user.email as string; + + // Конфиг подсказывает странице входа, чей это домен. + expect((await t.client(undefined, HOST).get('/api/config')).body.domain).toEqual({ company: co!.name, appUrl: 'https://app.test' }); + expect((await t.client().get('/api/config')).body.domain).toBeNull(); + + // Сотрудник компании входит и работает на её домене. + const ok = await loginOn(HOST, ownEmail); + expect(ok.status).toBe(200); + expect((await t.client(ok.body.token, HOST).get('/api/me')).body.company.id).toBe(own.id); + + // Чужой — отказ, сессия не создаётся; на основном домене он входит как обычно. + const denied = await loginOn(HOST, otherEmail); + expect(denied.body.code).toBe('WRONG_DOMAIN'); + expect(denied.body.token).toBeUndefined(); + const elsewhere = await loginOn(undefined, otherEmail); + expect(elsewhere.status).toBe(200); + // Его сессия, принесённая на чужой домен, не действует. + expect((await t.client(elsewhere.body.token, HOST).get('/api/me')).status).toBe(401); + expect((await t.client(elsewhere.body.token).get('/api/me')).status).toBe(200); + + // Суперадмин проходит. + const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin')); + expect((await t.client(await createSession(t.db, sa!.id), HOST).get('/api/me')).body.user.isSuperadmin).toBe(true); + + // Регистрация на домене компании закрыта. + const reg = await t.client(undefined, HOST).post('/api/auth/register', { name: 'Новый', email: 'new-on-arev@test.am', password: 'password-123' }); + expect(reg.body.code).toBe('WRONG_DOMAIN'); + + // Телеграм-вход: чужой пользователь получает отказ, новый не создаётся. + const tgUser = (id: number) => { + const data: Record = { id: String(id), first_name: 'Тест', auth_date: String(Math.floor(Date.now() / 1000)) }; + const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n'); + const secret = createHash('sha256').update(TEST_BOT_TOKEN).digest(); + return { ...data, hash: createHmac('sha256', secret).update(check).digest('hex') }; + }; + expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_001))).body.code).toBe('WRONG_DOMAIN'); + expect(await t.db.select().from(users).where(eq(users.telegramId, 9_001))).toHaveLength(0); + await t.db.update(users).set({ telegramId: 9_002 }).where(eq(users.email, otherEmail)); + expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_002))).body.code).toBe('WRONG_DOMAIN'); + await t.db.update(users).set({ telegramId: 9_003 }).where(eq(users.email, ownEmail)); + expect((await t.client(undefined, HOST).post('/api/auth/telegram', tgUser(9_003))).status).toBe(200); + }); +}); + +describe('вход', () => { + it('регистрация, вход, неверный пароль, выход', async () => { + const reg = await t.client().post('/api/auth/register', { name: 'Арам', email: 'Aram@Test.am', password: 'password-123' }); + expect(reg.status).toBe(200); + const dup = await t.client().post('/api/auth/register', { name: 'Арам', email: 'aram@test.am', password: 'password-123' }); + expect(dup.body.code).toBe('EMAIL_TAKEN'); + expect((await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'wrong-password' })).status).toBe(401); + const login = await t.client().post('/api/auth/login', { email: 'aram@test.am', password: 'password-123' }); + const c = t.client(login.body.token); + const me = await c.get('/api/me'); + expect(me.body.user).toMatchObject({ name: 'Арам', emailVerified: false, telegramLinked: false }); + expect(me.body.company).toBeNull(); + await c.post('/api/auth/logout'); + expect((await c.get('/api/me')).status).toBe(401); + }); + + it('подтверждение email и сброс пароля по ссылке из письма', async () => { + t.mails.length = 0; + await t.client().post('/api/auth/register', { name: 'Ани', email: 'ani@test.am', password: 'password-123' }); + const verify = new URL(t.mails[0]!.text).searchParams.get('token'); + expect((await t.client().post('/api/auth/verify-email', { token: verify })).status).toBe(200); + expect((await t.client().post('/api/auth/verify-email', { token: verify })).body.code).toBe('TOKEN_INVALID'); + + await t.client().post('/api/auth/forgot-password', { email: 'ani@test.am' }); + const reset = new URL(t.mails[1]!.text).searchParams.get('token'); + await t.client().post('/api/auth/reset-password', { token: reset, password: 'new-password-1' }); + expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'password-123' })).status).toBe(401); + expect((await t.client().post('/api/auth/login', { email: 'ani@test.am', password: 'new-password-1' })).status).toBe(200); + }); + + it('Telegram Mini App: верная подпись создаёт пользователя, подделка отклоняется', async () => { + const sign = (userId: number, token = TEST_BOT_TOKEN) => { + const p = new URLSearchParams({ + auth_date: String(Math.floor(Date.now() / 1000)), + user: JSON.stringify({ id: userId, first_name: 'Гор', language_code: 'hy' }), + }); + const check = [...p.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}=${v}`).join('\n'); + const secret = createHmac('sha256', 'WebAppData').update(token).digest(); + p.set('hash', createHmac('sha256', secret).update(check).digest('hex')); + return p.toString(); + }; + const ok = await t.client().post('/api/auth/telegram/webapp', { initData: sign(777) }); + expect(ok.status).toBe(200); + const me = await t.client(ok.body.token).get('/api/me'); + expect(me.body.user).toMatchObject({ name: 'Гор', lang: 'hy', telegramLinked: true }); + expect((await t.client().post('/api/auth/telegram/webapp', { initData: sign(778, 'other:token') })).status).toBe(401); + + // Mini App открыл владелец Telegram, у которого уже есть аккаунт: входит он, а не тот, чья сессия осталась в браузере. + const emailUser = await t.register(); + const own = await emailUser.post('/api/auth/telegram/webapp', { initData: sign(777) }); + expect((await t.client(own.body.token).get('/api/me')).body.user.id).toBe(me.body.user.id); + // Новый Telegram привязывается к аккаунту с email. + expect((await emailUser.post('/api/auth/telegram/webapp', { initData: sign(779) })).status).toBe(200); + expect((await emailUser.get('/api/me')).body.user.telegramLinked).toBe(true); + }); + + it('Telegram Login в веб-кабинете', async () => { + const data: Record = { id: 901, first_name: 'Лилит', auth_date: Math.floor(Date.now() / 1000) }; + const check = Object.keys(data).sort().map((k) => `${k}=${data[k]}`).join('\n'); + const hash = createHmac('sha256', createHash('sha256').update(TEST_BOT_TOKEN).digest()).update(check).digest('hex'); + expect((await t.client().post('/api/auth/telegram', { ...data, hash })).status).toBe(200); + expect((await t.client().post('/api/auth/telegram', { ...data, id: 902, hash })).status).toBe(401); + }); +}); + +describe('компании и тарифы', () => { + it('самостоятельная регистрация: компания создана, но доступ закрыт до оплаты', async () => { + const u = await t.register(); + expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED'); + expect((await u.post('/api/company', { name: 'Соло', plan: 'solo', billingModel: 'fixed' })).status).toBe(200); + const me = (await u.get('/api/me')).body; + expect(me.user.role).toBe('manager'); + expect(me.company).toMatchObject({ plan: 'solo', access: 'disabled', modules: [], maxMembers: 1, members: 1 }); + // Solo работает в Telegram: из браузера рабочие разделы закрыты, из Mini App — открыты. + expect(me.webBlocked).toBe(true); + expect((await u.get('/api/leads')).body.code).toBe('WEB_NOT_AVAILABLE'); + expect((await u.get('/api/billing')).status).toBe(200); + const mini = t.client(await createSession(t.db, me.user.id, true)); + expect((await mini.get('/api/me')).body.webBlocked).toBe(false); + expect((await mini.get('/api/leads')).status).toBe(200); + expect((await mini.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED'); + }); + + it('компания получает обе воронки с системными статусами', async () => { + const { id } = await company(); + const rows = await t.db.select().from(pipelineStatuses).where(eq(pipelineStatuses.companyId, id)); + const codes = (p: string) => rows.filter((r) => r.pipeline === p && r.code).map((r) => r.code); + expect(rows.filter((r) => r.pipeline === 'object')).toHaveLength(8); + expect(rows.filter((r) => r.pipeline === 'client')).toHaveLength(7); + expect(codes('object')).toEqual(['new', 'taken', 'deal', 'rejected']); + expect(codes('client')).toEqual(['new', 'deal', 'rejected']); + }); + + it('в Team нельзя добавить одиннадцатого участника, даже одновременно', async () => { + const { boss } = await company('team'); + const invite = (await boss.post('/api/invites')).body.token as string; + expect((await t.client().get(`/api/invites/${invite}`)).body.companyName).toBe('Агентство team'); + const agents = await Promise.all(Array.from({ length: 11 }, () => t.register('Агент'))); + const results = await Promise.all(agents.map((a) => a.post(`/api/invites/${invite}/accept`))); + expect(results.filter((r) => r.status === 200)).toHaveLength(9); + expect(results.filter((r) => r.body.code === 'MEMBER_LIMIT_REACHED')).toHaveLength(2); + expect(results.filter((r) => r.body.limitReached)).toHaveLength(1); + expect((await boss.get('/api/me')).body.company.members).toBe(10); + }); + + it('ссылка руководителя одноразовая, отозванное приглашение не работает', async () => { + const { boss, managerToken } = await company(); + expect((await (await t.register()).post(`/api/invites/${managerToken}/accept`)).body.code).toBe('INVITE_NOT_FOUND'); + const invite = (await boss.post('/api/invites')).body.token as string; + await boss.del(`/api/invites/${invite}`); + expect((await (await t.register()).post(`/api/invites/${invite}/accept`)).body.code).toBe('INVITE_NOT_FOUND'); + }); + + it('агент не управляет компанией и не видит чужую; удалённый агент теряет доступ', async () => { + const a = await company(); + const b = await company(); + const invite = (await a.boss.post('/api/invites')).body.token as string; + const agent = await t.register('Агент'); + await agent.post(`/api/invites/${invite}/accept`); + expect((await agent.post('/api/invites')).status).toBe(403); + expect((await agent.patch('/api/company', { agentsSeeAll: true })).status).toBe(403); + expect((await agent.get('/api/admin/companies')).status).toBe(403); + + const members = (await a.boss.get('/api/members')).body as { id: number; role: string }[]; + const agentId = members.find((m) => m.role === 'agent')!.id; + expect((await b.boss.del(`/api/members/${agentId}`)).status).toBe(404); + expect((await a.boss.del(`/api/members/${agentId}`)).status).toBe(200); + expect((await agent.get('/api/me')).status).toBe(401); + expect((await a.boss.get('/api/members')).body).toHaveLength(1); + }); + + it('просрочка: 15 дней сервис работает, на 16-й — только чтение; суперадмин возвращает доступ', async () => { + const { sa, boss, id } = await company(); + const daysAgo = (d: number) => new Date(Date.now() - d * 86_400_000); + await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(14) }).where(eq(companies.id, id)); + expect((await boss.get('/api/me')).body.company.access).toBe('overdue'); + expect((await boss.patch('/api/company', { agentsSeeAll: true })).status).toBe(200); + + await t.db.update(companies).set({ subscriptionEndsAt: daysAgo(16) }).where(eq(companies.id, id)); + expect((await boss.get('/api/me')).body.company.access).toBe('restricted'); + expect((await boss.patch('/api/company', { agentsSeeAll: false })).body.code).toBe('SUBSCRIPTION_REQUIRED'); + expect((await boss.get('/api/members')).status).toBe(200); + + await sa.patch(`/api/admin/companies/${id}`, { subscriptionEndsAt: new Date(Date.now() + 86_400_000).toISOString() }); + expect((await boss.get('/api/me')).body.company.access).toBe('active'); + expect((await boss.get('/api/me')).body.company.settings.agentsSeeAll).toBe(true); + }); + + it('суперадмин: список компаний, вход в кабинет для поддержки, запрет «%» на Solo', async () => { + const { sa, id } = await company('agency'); + const list = (await sa.get('/api/admin/companies')).body as { id: number; members: number; maxMembers: number }[]; + expect(list.find((c) => c.id === id)).toMatchObject({ members: 1, maxMembers: 35, access: 'active' }); + expect((await sa.patch(`/api/admin/companies/${id}`, { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED'); + + expect((await sa.get('/api/members')).body.code).toBe('NO_COMPANY'); + await sa.post('/api/admin/impersonate', { companyId: id }); + const me = (await sa.get('/api/me')).body; + expect(me).toMatchObject({ impersonating: true, company: { id }, user: { role: 'manager', isSuperadmin: true } }); + expect((await sa.get('/api/members')).body).toHaveLength(1); + await sa.post('/api/admin/impersonate', { companyId: null }); + expect((await sa.get('/api/me')).body.company).toBeNull(); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..dc4b63c --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,871 @@ +import cookie from '@fastify/cookie'; +import { + accessState, + BILLING_MODELS, + canWrite, + CommissionSettings, + hasModule, + LANGS, + PLAN_SPECS, + PLANS, + type AccessState, + type AgentPay, + type BillingModel, + type Lang, + type Plan, + type Role, +} from '@lr/shared'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; +import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { hashPassword, verifyPassword } from './auth/password.ts'; +import { + createSession, + deleteSession, + findSession, + hashToken, + newToken, + SESSION_COOKIE, + SESSION_DAYS, +} from './auth/sessions.ts'; +import { verifyLoginWidget, verifyWebAppInitData, type TelegramUser } from './auth/telegram.ts'; +import { + acceptInvite, + createCompany, + createInvite, + memberCount, +} from './companies/companies.service.ts'; +import type { Db } from './db/client.ts'; +import { + clients, + botLangs, + companies, + departments, + emailTokens, + invites, + leads, + listings, + parserRuns, + pipelineStatuses, + sessions, + users, +} from './db/schema.ts'; +import type { Config } from './lib/config.ts'; +import { AppError, badRequest, conflict, forbidden, notFound } from './lib/errors.ts'; +import { onlySent } from './lib/patch.ts'; +import { consoleMailer, type Mailer } from './lib/mailer.ts'; +import { registerAdminListingRoutes } from './admin-listings.ts'; +import { registerAnalyticsRoutes } from './analytics/routes.ts'; +import type { PaymentProvider } from './billing/provider.ts'; +import { registerBillingRoutes } from './billing/routes.ts'; +import { applyPlan, issueInitialInvoices, issueSetupIfNeeded } from './billing/service.ts'; +import { registerArchiveRoutes } from './archive/routes.ts'; +import { registerClientRoutes } from './clients/routes.ts'; +import { registerDepartmentRoutes } from './departments/routes.ts'; +import type { GroupSender } from './group/service.ts'; +import { registerLeadRoutes } from './leads/routes.ts'; +import { LISTAM_CATEGORIES } from './parser/listam-source.ts'; +import { loadParserSettings, ParserSettings, saveParserSettings } from './parser/settings.ts'; +import { registerPipelineRoutes } from './pipelines/routes.ts'; +import { runIngest, type PagedSource } from './ingest/service.ts'; +import type { ImageFetcher } from './showcase/media.ts'; +import { registerPayrollRoutes } from './payroll.ts'; +import { registerShowcaseRoutes } from './showcase/routes.ts'; +import { registerBarevRoutes } from './barev/routes.ts'; +import { validSlug } from './showcase/service.ts'; +import { registerSupportRoutes } from './support/routes.ts'; +import { telegramFileSender, type FileSender } from './support/service.ts'; +import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts'; +import { registerBotStartRoutes } from './telegram/start.ts'; +import { enqueueText } from './telegram/outbox.ts'; + +type User = typeof users.$inferSelect; +type Company = typeof companies.$inferSelect; + +export interface Ctx { + user: User; + /** Роль в текущем кабинете: суперадмин, вошедший в компанию для поддержки, действует как руководитель. */ + role: Role; + company: Company | null; + access: AccessState | null; + /** Суперадмин работает в кабинете компании. */ + impersonating: boolean; + /** Тариф без веб-кабинета, а сессия открыта не из Telegram Mini App: рабочие разделы закрыты. */ + webBlocked: boolean; +} + +declare module 'fastify' { + interface FastifyRequest { + ctx: Ctx | null; + sessionToken: string | null; + /** Компания, за которой закреплён домен запроса; на основном домене — null. */ + domain: Company | null; + } +} + +export interface AppDeps { + db: Db; + config: Config; + mailer?: Mailer; + /** Источник объявлений — для ручного запуска парсера суперадмином. */ + source?: PagedSource | null; + /** Платёжный провайдер. Пока не выбран — онлайн-оплата выключена, счета отмечает суперадмин. */ + payments?: PaymentProvider | null; + /** Отправка файлов поддержки в Telegram; в тестах подменяется заглушкой. */ + sendFile?: FileSender; + /** Скачивание фото объявлений для страницы клиента; в тестах подменяется. */ + fetchImage?: ImageFetcher; + /** Отправка в группу компании в Telegram; в тестах подменяется. */ + groupSender?: GroupSender; +} + +const Email = z.string().trim().toLowerCase().pipe(z.email()); +const Password = z.string().min(8).max(200); +const Name = z.string().trim().min(1).max(100); +const Lang_ = z.enum(LANGS); + +const langOf = (code: string | undefined): Lang => + (LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru'; + +export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender }: AppDeps) { + const app = Fastify({ logger: false }); + void app.register(cookie); + // Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут. + app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer', bodyLimit: 20 * 1024 * 1024 }, (_req, body, done) => done(null, body)); + + app.setErrorHandler((error, _req, reply) => { + if (error instanceof AppError) { + return reply.status(error.status).send({ code: error.code }); + } + if (error instanceof z.ZodError) { + return reply.status(400).send({ code: 'VALIDATION', issues: error.issues }); + } + const status = (error as { statusCode?: number }).statusCode; + if (status && status < 500) return reply.status(status).send({ code: 'BAD_REQUEST' }); + console.error(error); + return reply.status(500).send({ code: 'INTERNAL' }); + }); + + // --- контекст запроса: пользователь, компания, состояние подписки --- + + // Свой домен компании (settings.clientHost, например arev.yandp.tech): ссылки её клиентам и вход + // только для её сотрудников и суперадмина. Сессия чужого пользователя на таком домене не действует. + const mainHost = new URL(config.appUrl).hostname; + const domainOf = async (req: FastifyRequest) => { + const host = req.hostname.toLowerCase(); + if (host === mainHost) return null; + const [company] = await db.select().from(companies).where(sql`${companies.settings}->>'clientHost' = ${host}`); + return company ?? null; + }; + const allowedOn = (domain: Company | null, user: User) => + !domain || user.role === 'superadmin' || user.companyId === domain.id; + const checkDomain = (req: FastifyRequest, user: User) => { + if (!allowedOn(req.domain, user)) throw forbidden('WRONG_DOMAIN'); + }; + + app.decorateRequest('ctx', null); + app.decorateRequest('sessionToken', null); + app.decorateRequest('domain', null); + app.addHook('preHandler', async (req) => { + req.domain = await domainOf(req); + const bearer = req.headers.authorization?.match(/^Bearer (.+)$/)?.[1]; + const token = bearer ?? req.cookies[SESSION_COOKIE]; + if (!token) return; + const session = await findSession(db, token); + if (!session || session.user.removedAt || !allowedOn(req.domain, session.user)) return; + const { user } = session; + const impersonating = user.role === 'superadmin' && session.actingCompanyId !== null; + const companyId = impersonating ? session.actingCompanyId : user.companyId; + const [company] = companyId + ? await db.select().from(companies).where(eq(companies.id, companyId)) + : []; + req.sessionToken = token; + req.ctx = { + user, + role: impersonating ? 'manager' : (user.role as Role), + company: company ?? null, + access: company ? accessState(company) : null, + impersonating, + webBlocked: Boolean(company) && !impersonating && !session.viaTelegram && !hasModule(company!.plan as Plan, 'web'), + }; + }); + + const auth = (req: FastifyRequest): Ctx => { + if (!req.ctx) throw new AppError(401, 'UNAUTHORIZED'); + return req.ctx; + }; + /** Пользователь внутри компании. Все бизнес-запросы идут через эту проверку. */ + const account = (req: FastifyRequest) => { + const ctx = auth(req); + if (!ctx.company) throw forbidden('NO_COMPANY'); + return ctx as Ctx & { company: Company; access: AccessState }; + }; + const member = (req: FastifyRequest) => { + const ctx = account(req); + if (ctx.webBlocked) throw forbidden('WEB_NOT_AVAILABLE'); + return ctx; + }; + /** Руководитель в разделе «Подписка»: доступен с любого интерфейса и при ограниченном доступе. */ + const owner = (req: FastifyRequest) => { + const ctx = account(req); + if (ctx.role !== 'manager') throw forbidden(); + return ctx; + }; + const manager = (req: FastifyRequest) => { + const ctx = member(req); + if (ctx.role !== 'manager') throw forbidden(); + return ctx; + }; + /** Изменения запрещены, когда отсрочка истекла или доступ выключен (CRM только на чтение). */ + const writable = (ctx: T): T => { + if (!canWrite(ctx.access)) throw forbidden('SUBSCRIPTION_REQUIRED'); + return ctx; + }; + const superadmin = (req: FastifyRequest) => { + const ctx = auth(req); + if (ctx.user.role !== 'superadmin') throw forbidden(); + return ctx; + }; + + const startSession = async (reply: FastifyReply, userId: number, viaTelegram = false) => { + const token = await createSession(db, userId, viaTelegram); + reply.setCookie(SESSION_COOKIE, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: config.production, + maxAge: SESSION_DAYS * 86_400, + }); + return token; + }; + + const sendEmailToken = async (user: User, kind: 'verify' | 'reset') => { + if (!user.email) return; + const token = newToken(); + await db.insert(emailTokens).values({ + tokenHash: hashToken(token), + userId: user.id, + kind, + expiresAt: new Date(Date.now() + (kind === 'verify' ? 72 : 2) * 3_600_000), + }); + const link = `${config.appUrl}/${kind === 'verify' ? 'verify-email' : 'reset-password'}?token=${token}`; + await mailer.send({ + to: user.email, + subject: kind === 'verify' ? 'Y&P Lead Radar: подтвердите email' : 'Y&P Lead Radar: сброс пароля', + text: link, + }); + }; + + const takeEmailToken = async (token: string, kind: 'verify' | 'reset') => { + const [row] = await db + .delete(emailTokens) + .where(and(eq(emailTokens.tokenHash, hashToken(token)), eq(emailTokens.kind, kind))) + .returning(); + if (!row || row.expiresAt < new Date()) throw badRequest('TOKEN_INVALID'); + return row.userId; + }; + + const me = async (ctx: Ctx) => { + const { user, company } = ctx; + return { + user: { + id: user.id, + name: user.name, + email: user.email, + emailVerified: Boolean(user.emailVerifiedAt), + telegramLinked: Boolean(user.telegramId), + hasPassword: Boolean(user.passwordHash), + lang: user.lang, + role: ctx.role, + isSuperadmin: user.role === 'superadmin', + quietFrom: user.quietFrom, + quietTo: user.quietTo, + notificationsPaused: user.notificationsPaused, + onboarded: Boolean(user.onboardedAt), + /** Отдел сотрудника: он видит только объекты и клиентов этого отдела. */ + departmentId: user.departmentId, + departmentName: user.departmentId + ? ((await db.select({ name: departments.name }).from(departments).where(eq(departments.id, user.departmentId)))[0]?.name ?? null) + : null, + }, + impersonating: ctx.impersonating, + webBlocked: ctx.webBlocked, + company: company && { + id: company.id, + name: company.name, + slug: company.slug, + plan: company.plan, + billingModel: company.billingModel, + planned: + company.plannedFrom && company.plannedPlan + ? { + plan: company.plannedPlan, + billingModel: company.plannedBillingModel, + from: company.plannedFrom, + } + : null, + subscriptionEndsAt: company.subscriptionEndsAt, + graceDays: company.graceDays, + access: ctx.access, + modules: PLAN_SPECS[company.plan as Plan].modules, + maxMembers: PLAN_SPECS[company.plan as Plan].maxMembers, + members: await memberCount(db, company.id), + // Токен бота группы и ключ сайта BarevClub — секреты: в кабинет они не уходят. + settings: { ...company.settings, groupBot: undefined, barevClub: undefined }, + /** У компании настроен бот группы: в карточке объекта работает «Отправить в Telegram». */ + groupBot: Boolean(company.settings.groupBot?.chatId), + }, + }; + }; + + // --- вход и регистрация --- + + app.get('/api/health', async () => { + await db.execute(sql`select 1`); + return { status: 'ok' }; + }); + + app.get('/api/config', async (req) => ({ + telegramBotUsername: config.telegramBotToken ? (config.telegramBotUsername ?? null) : null, + onlinePayments: Boolean(payments), + /** На своём домене компании: её название и основной адрес сервиса для остальных. */ + domain: req.domain && { company: req.domain.name, appUrl: config.appUrl }, + })); + + app.post('/api/auth/register', async (req, reply) => { + const body = z + .object({ name: Name, email: Email, password: Password, lang: Lang_.default('ru') }) + .parse(req.body); + // На своём домене компании регистрации нет: сотрудников туда приглашают с основного адреса. + if (req.domain) throw forbidden('WRONG_DOMAIN'); + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(sql`lower(${users.email}) = ${body.email}`); + if (existing) throw conflict('EMAIL_TAKEN'); + const [user] = await db + .insert(users) + .values({ + name: body.name, + email: body.email, + passwordHash: await hashPassword(body.password), + lang: body.lang, + // Роль уточняется при создании компании (manager) или принятии приглашения (agent). + role: 'agent', + }) + .returning(); + await sendEmailToken(user!, 'verify'); + await enqueueForSuperadmins(db, 'signupNew', [user!.name, contactOf(user!)]); + const token = await startSession(reply, user!.id); + return { token }; + }); + + app.post('/api/auth/login', async (req, reply) => { + const body = z.object({ email: Email, password: z.string().max(200) }).parse(req.body); + const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${body.email}`); + // Пароль проверяется и для несуществующего пользователя — чтобы ответ не выдавал, есть ли такой email. + const ok = await verifyPassword(body.password, user?.passwordHash ?? null); + if (!user || !ok || user.removedAt) throw new AppError(401, 'BAD_CREDENTIALS'); + checkDomain(req, user); + const token = await startSession(reply, user.id); + return { token }; + }); + + app.post('/api/auth/logout', async (req, reply) => { + if (req.sessionToken) await deleteSession(db, req.sessionToken); + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + return { ok: true }; + }); + + app.post('/api/auth/verify-email', async (req) => { + const { token } = z.object({ token: z.string().min(1) }).parse(req.body); + const userId = await takeEmailToken(token, 'verify'); + await db.update(users).set({ emailVerifiedAt: new Date() }).where(eq(users.id, userId)); + return { ok: true }; + }); + + app.post('/api/auth/forgot-password', async (req) => { + const { email } = z.object({ email: Email }).parse(req.body); + const [user] = await db.select().from(users).where(sql`lower(${users.email}) = ${email}`); + if (user && !user.removedAt) await sendEmailToken(user, 'reset'); + // Ответ одинаковый, есть такой email или нет. + return { ok: true }; + }); + + app.post('/api/auth/reset-password', async (req) => { + const body = z.object({ token: z.string().min(1), password: Password }).parse(req.body); + const userId = await takeEmailToken(body.token, 'reset'); + await db + .update(users) + .set({ passwordHash: await hashPassword(body.password), emailVerifiedAt: new Date() }) + .where(eq(users.id, userId)); + // Старые сессии закрываются: пароль мог сбросить не тот, кто сейчас вошёл. + await db.delete(sessions).where(eq(sessions.userId, userId)); + return { ok: true }; + }); + + /** + * Вход через Telegram. Если запрос пришёл от уже вошедшего пользователя, + * Telegram привязывается к его аккаунту; иначе — вход или регистрация. + */ + const telegramAuth = async (req: FastifyRequest, reply: FastifyReply, tg: TelegramUser | null, viaTelegram = false) => { + if (!tg) throw new AppError(401, 'TELEGRAM_AUTH_FAILED'); + const [existing] = await db.select().from(users).where(eq(users.telegramId, tg.id)); + // Mini App открыл владелец этого Telegram: его аккаунт важнее сессии, оставшейся в браузере Telegram. + if (existing && !existing.removedAt) checkDomain(req, existing); + else if (!existing && req.domain && !req.ctx) throw forbidden('WRONG_DOMAIN'); + if (viaTelegram && existing && !existing.removedAt) { + return { token: await startSession(reply, existing.id, true) }; + } + if (req.ctx) { + if (existing && existing.id !== req.ctx.user.id) throw conflict('TELEGRAM_TAKEN'); + await db + .update(users) + .set({ telegramId: tg.id, telegramUsername: tg.username ?? null }) + .where(eq(users.id, req.ctx.user.id)); + if (viaTelegram) { + await db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.tokenHash, hashToken(req.sessionToken!))); + } + return { token: req.sessionToken }; + } + if (existing?.removedAt) throw new AppError(401, 'TELEGRAM_AUTH_FAILED'); + const fresh = !existing; + // Язык, выбранный в боте до регистрации, важнее языка интерфейса Telegram. + const [chosen] = fresh ? await db.select().from(botLangs).where(eq(botLangs.telegramId, tg.id)) : []; + const user = + existing ?? + ( + await db + .insert(users) + .values({ + name: [tg.firstName, tg.lastName].filter(Boolean).join(' ') || tg.username || 'Telegram', + telegramId: tg.id, + telegramUsername: tg.username ?? null, + lang: (chosen?.lang as Lang | undefined) ?? langOf(tg.languageCode), + role: 'agent', + }) + .returning() + )[0]!; + if (fresh) await enqueueForSuperadmins(db, 'signupNew', [user.name, contactOf(user)]); + const token = await startSession(reply, user.id, viaTelegram); + return { token }; + }; + + app.post('/api/auth/telegram', async (req, reply) => { + if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED'); + const data = z.record(z.string(), z.union([z.string(), z.number()])).parse(req.body); + return telegramAuth(req, reply, verifyLoginWidget(data, config.telegramBotToken)); + }); + + app.post('/api/auth/telegram/webapp', async (req, reply) => { + if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED'); + const { initData } = z.object({ initData: z.string().min(1) }).parse(req.body); + return telegramAuth(req, reply, verifyWebAppInitData(initData, config.telegramBotToken), true); + }); + + // --- профиль --- + + app.get('/api/me', async (req) => me(auth(req))); + + app.patch('/api/me', async (req) => { + const ctx = auth(req); + const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).nullable(); + const body = z + .object({ + name: Name, + lang: Lang_, + quietFrom: time, + quietTo: time, + notificationsPaused: z.boolean(), + /** Мастер первой настройки пройден или пропущен. */ + onboarded: z.literal(true), + }) + .partial() + .parse(req.body); + const { onboarded, ...fields } = body; + const patch = { ...fields, ...(onboarded ? { onboardedAt: new Date() } : {}) }; + if (Object.keys(patch).length) await db.update(users).set(patch).where(eq(users.id, ctx.user.id)); + return { ok: true }; + }); + + // --- компания --- + + /** Самостоятельное подключение: доступ откроется после оплаты или включения суперадмином. */ + app.post('/api/company', async (req) => { + const ctx = auth(req); + if (ctx.company || ctx.user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY'); + const body = z + .object({ name: Name, plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) }) + .parse(req.body); + const company = await db.transaction(async (tx) => { + const created = await createCompany(tx, { ...body, lang: ctx.user.lang as Lang }); + await tx + .update(users) + .set({ companyId: created.id, role: 'manager', removedAt: null }) + .where(eq(users.id, ctx.user.id)); + await issueInitialInvoices(tx, created); + await enqueueForSuperadmins(tx, 'signupCompany', [created.name, created.plan, ctx.user.name, contactOf(ctx.user)]); + return created; + }); + return { id: company.id }; + }); + + app.patch('/api/company', async (req) => { + const ctx = writable(manager(req)); + const body = z + .object({ + name: Name, + /** Имя компании в адресе страниц для клиентов. */ + slug: z.string().trim().toLowerCase(), + agentsSeeAll: z.boolean(), + autoRejectRemoved: z.boolean(), + poolReturnHours: z.number().int().min(1).max(720), + useAgencyBadge: z.boolean(), + phoneThreshold: z.number().int().min(1).max(100), + /** Комиссия агентства по умолчанию. */ + commission: CommissionSettings, + /** Отчёты руководителю в Telegram. */ + reportWeekly: z.boolean(), + reportMonthly: z.boolean(), + }) + .partial() + .parse(req.body); + const s = ctx.company.settings; + if (body.slug !== undefined && body.slug !== ctx.company.slug) { + if (!validSlug(body.slug)) throw badRequest('BAD_SLUG'); + const [taken] = await db.select({ id: companies.id }).from(companies).where(eq(companies.slug, body.slug)); + if (taken) throw conflict('SLUG_TAKEN'); + } + await db + .update(companies) + .set({ + name: body.name ?? ctx.company.name, + slug: body.slug ?? ctx.company.slug, + settings: { + agentsSeeAll: body.agentsSeeAll ?? s.agentsSeeAll, + autoRejectRemoved: body.autoRejectRemoved ?? s.autoRejectRemoved ?? false, + poolReturnHours: body.poolReturnHours ?? s.poolReturnHours, + agentRules: { + useAgencyBadge: body.useAgencyBadge ?? s.agentRules.useAgencyBadge, + phoneThreshold: body.phoneThreshold ?? s.agentRules.phoneThreshold, + }, + commission: body.commission ?? s.commission, + reports: { ...s.reports, weekly: body.reportWeekly ?? s.reports?.weekly ?? false, monthly: body.reportMonthly ?? s.reports?.monthly ?? false }, + clientHost: s.clientHost, + groupBot: s.groupBot, + barevClub: s.barevClub, + }, + }) + .where(eq(companies.id, ctx.company.id)); + return { ok: true }; + }); + + // --- участники и приглашения --- + + app.get('/api/members', async (req) => { + const ctx = member(req); + return db + .select({ + id: users.id, + name: users.name, + role: users.role, + email: users.email, + telegramUsername: users.telegramUsername, + telegramLinked: sql`${users.telegramId} is not null`, + departmentId: users.departmentId, + // Условия оплаты видит руководитель; агент — только свои. + pay: ctx.role === 'manager' ? users.pay : sql`case when ${users.id} = ${ctx.user.id} then ${users.pay} end`, + }) + .from(users) + .where(and(eq(users.companyId, ctx.company.id), isNull(users.removedAt))) + .orderBy(users.id); + }); + + /** Удаление агента: его лиды уходят выбранному агенту либо возвращаются в пул. История сохраняется. */ + app.delete('/api/members/:id', async (req) => { + const ctx = writable(manager(req)); + const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params); + const { reassignTo } = z + .object({ reassignTo: z.coerce.number().int().optional() }) + .parse(req.query); + if (id === ctx.user.id) throw badRequest('CANNOT_REMOVE_SELF'); + await db.transaction(async (tx) => { + const inCompany = and(eq(users.companyId, ctx.company.id), isNull(users.removedAt)); + const [target] = await tx.select().from(users).where(and(eq(users.id, id), inCompany)); + if (!target || target.role !== 'agent') throw notFound(); + if (reassignTo !== undefined) { + const [heir] = await tx.select().from(users).where(and(eq(users.id, reassignTo), inCompany)); + if (!heir || heir.id === id) throw badRequest('BAD_REASSIGN_TARGET'); + } + // Без наследника лиды возвращаются в пул в статусе «Новый». + const [fresh] = await tx + .select({ id: pipelineStatuses.id }) + .from(pipelineStatuses) + .where( + and( + eq(pipelineStatuses.companyId, ctx.company.id), + eq(pipelineStatuses.pipeline, 'object'), + eq(pipelineStatuses.code, 'new'), + ), + ); + await tx + .update(leads) + .set( + reassignTo !== undefined + ? { assigneeId: reassignTo } + : { assigneeId: null, statusId: fresh!.id, statusChangedAt: new Date(), takenAt: null }, + ) + .where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id))); + // Клиенты всегда закреплены за агентом: без наследника их забирает руководитель. + await tx + .update(clients) + .set({ assigneeId: reassignTo ?? ctx.user.id }) + .where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id))); + await tx.update(users).set({ removedAt: new Date() }).where(eq(users.id, id)); + await tx.delete(sessions).where(eq(sessions.userId, id)); + }); + return { ok: true }; + }); + + app.get('/api/invites', async (req) => { + const ctx = manager(req); + const rows = await db + .select() + .from(invites) + .where(and(eq(invites.companyId, ctx.company.id), isNull(invites.revokedAt), eq(invites.role, 'agent'))) + .orderBy(desc(invites.createdAt)); + return rows.map((i) => ({ + token: i.token, + url: `${config.appUrl}/invite/${i.token}`, + createdAt: i.createdAt, + })); + }); + + app.post('/api/invites', async (req) => { + const ctx = writable(manager(req)); + const invite = await createInvite(db, ctx.company.id, ctx.user.id); + return { token: invite.token, url: `${config.appUrl}/invite/${invite.token}` }; + }); + + app.delete('/api/invites/:token', async (req) => { + const ctx = manager(req); + const { token } = z.object({ token: z.string() }).parse(req.params); + await db + .update(invites) + .set({ revokedAt: new Date() }) + .where(and(eq(invites.token, token), eq(invites.companyId, ctx.company.id))); + return { ok: true }; + }); + + /** Что за приглашение — показывается до входа. */ + app.get('/api/invites/:token', async (req) => { + const { token } = z.object({ token: z.string() }).parse(req.params); + const [row] = await db + .select({ name: companies.name, revokedAt: invites.revokedAt }) + .from(invites) + .innerJoin(companies, eq(companies.id, invites.companyId)) + .where(eq(invites.token, token)); + if (!row || row.revokedAt) throw notFound('INVITE_NOT_FOUND'); + return { companyName: row.name }; + }); + + app.post('/api/invites/:token/accept', async (req) => { + const ctx = auth(req); + const { token } = z.object({ token: z.string() }).parse(req.params); + const result = await acceptInvite(db, token, ctx.user.id); + // Руководителям в бот: агент присоединился; достигнут лимит участников. + const managers = ( + await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.companyId, result.companyId), eq(users.role, 'manager'), isNull(users.removedAt))) + ) + .map((m) => m.id) + .filter((id) => id !== ctx.user.id); + await enqueueText(db, managers, 'joined', [ctx.user.name]); + if (result.limitReached) await enqueueText(db, managers, 'limitReached'); + return result; + }); + + // --- суперадмин --- + + app.get('/api/admin/companies', async (req) => { + superadmin(req); + const rows = await db + .select({ + company: companies, + members: sql`(select count(*)::int from users u where u.company_id = "companies"."id" and u.removed_at is null)`, + }) + .from(companies) + .orderBy(desc(companies.id)); + return rows.map(({ company: c, members }) => ({ + id: c.id, + name: c.name, + plan: c.plan, + billingModel: c.billingModel, + members, + maxMembers: PLAN_SPECS[c.plan as Plan].maxMembers, + access: accessState(c), + accessEnabled: c.accessEnabled, + subscriptionEndsAt: c.subscriptionEndsAt, + graceDays: c.graceDays, + lastActivityAt: c.lastActivityAt, + createdAt: c.createdAt, + })); + }); + + /** Ручное подключение: компания создаётся включённой, руководитель входит по ссылке-приглашению. */ + app.post('/api/admin/companies', async (req) => { + const ctx = superadmin(req); + const body = z + .object({ + name: Name, + plan: z.enum(PLANS), + billingModel: z.enum(BILLING_MODELS), + subscriptionEndsAt: z.coerce.date(), + lang: Lang_.default('ru'), + }) + .parse(req.body); + return db.transaction(async (tx) => { + const company = await createCompany(tx, { ...body, accessEnabled: true }); + await issueSetupIfNeeded(tx, company, false); + const [invite] = await tx + .insert(invites) + .values({ token: newToken(), role: 'manager', companyId: company.id, createdById: ctx.user.id }) + .returning(); + return { id: company.id, managerUrl: `${config.appUrl}/invite/${invite!.token}` }; + }); + }); + + app.patch('/api/admin/companies/:id', async (req) => { + superadmin(req); + const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params); + const body = z + .object({ + plan: z.enum(PLANS), + billingModel: z.enum(BILLING_MODELS), + subscriptionEndsAt: z.coerce.date().nullable(), + graceDays: z.number().int().min(0).max(365), + accessEnabled: z.boolean(), + }) + .partial() + .parse(req.body); + const [company] = await db.select().from(companies).where(eq(companies.id, id)); + if (!company) throw notFound(); + const plan = body.plan ?? (company.plan as Plan); + const model = body.billingModel ?? company.billingModel; + if (model === 'percent' && PLAN_SPECS[plan].commissionPercent === null) { + throw badRequest('BILLING_MODEL_NOT_ALLOWED'); + } + if (body.plan && (await memberCount(db, id)) > PLAN_SPECS[plan].maxMembers) { + throw conflict('MEMBER_LIMIT_REACHED'); + } + await db.transaction(async (tx) => { + const { plan: _plan, billingModel: _model, ...rest } = body; + if (Object.keys(rest).length) await tx.update(companies).set(rest).where(eq(companies.id, id)); + // Суперадмин меняет тариф сразу, без ожидания следующего месяца. + if (plan !== company.plan || model !== company.billingModel) { + const [fresh] = await tx.select().from(companies).where(eq(companies.id, id)); + await applyPlan(tx, fresh!, plan, model as BillingModel); + } + }); + return { ok: true }; + }); + + /** Вход в кабинет компании для поддержки (id = null — выйти обратно в админку). */ + app.post('/api/admin/impersonate', async (req) => { + superadmin(req); + const { companyId } = z.object({ companyId: z.number().int().nullable() }).parse(req.body); + if (companyId !== null) { + const [company] = await db.select({ id: companies.id }).from(companies).where(eq(companies.id, companyId)); + if (!company) throw notFound(); + } + await db + .update(sessions) + .set({ actingCompanyId: companyId }) + .where(eq(sessions.tokenHash, hashToken(req.sessionToken!))); + return { ok: true }; + }); + + /** Ссылка привязки Telegram: пользователь открывает бота, тот запоминает его чат. */ + app.post('/api/me/telegram-link', async (req) => { + const ctx = auth(req); + if (!config.telegramBotToken || !config.telegramBotUsername) throw badRequest('TELEGRAM_DISABLED'); + const code = newToken().slice(0, 32); + await db.update(users).set({ telegramLinkToken: code }).where(eq(users.id, ctx.user.id)); + return { url: `https://t.me/${config.telegramBotUsername}?start=${code}` }; + }); + + // --- суперадмин: парсер --- + + app.get('/api/admin/parser', async (req) => { + superadmin(req); + const [runs, [stats]] = await Promise.all([ + db.select().from(parserRuns).orderBy(desc(parserRuns.id)).limit(20), + db + .select({ + lastOk: sql`(select max(finished_at) from parser_runs where status = 'ok')`, + listings24h: sql`(select count(*)::int from ${listings} where created_at > now() - interval '24 hours')`, + listingsTotal: sql`(select count(*)::int from ${listings})`, + // Сколько длится полный круг парсера: проход плюс пауза до следующего. + cycleSec: sql`(select round(avg(sec))::int from (select extract(epoch from started_at - lag(started_at) over (order by id)) as sec from parser_runs order by id desc limit 20) x)`, + // Задержка «размещено на list.am → лид создан» за сутки. Считаются только объявления, + // пойманные в день размещения, и только если известно точное время размещения. + delayMedianSec: sql`(select round(percentile_cont(0.5) within group (order by extract(epoch from d.created_at - l.published_at)))::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`, + delayCount: sql`(select count(*)::int from ${leads} d join ${listings} l on l.id = d.listing_id where d.created_at > now() - interval '24 hours' and d.created_at - l.published_at < interval '6 hours' and d.created_at - l.created_at < interval '1 minute' and extract(second from l.published_at) + extract(minute from l.published_at) > 0)`, + }) + .from(sql`(select 1) as one`), + ]); + return { enabled: Boolean(source), source: source?.name ?? null, ...stats, runs }; + }); + + /** Настройки парсера. Ключ ридера наружу не отдаётся — только признак и последние символы. */ + const parserSettingsView = async () => { + const { jinaApiKey, ...rest } = await loadParserSettings(db); + const key = jinaApiKey || config.jinaApiKey || ''; + return { + ...rest, + jinaKey: { source: jinaApiKey ? 'settings' : config.jinaApiKey ? 'env' : 'none', tail: key ? key.slice(-4) : null }, + allCategories: LISTAM_CATEGORIES, + }; + }; + + app.get('/api/admin/parser/settings', async (req) => { + superadmin(req); + return parserSettingsView(); + }); + + app.patch('/api/admin/parser/settings', async (req) => { + superadmin(req); + const patch = onlySent(req.body, ParserSettings.partial().parse(req.body)); + if (patch.categories) { + const known = new Set(LISTAM_CATEGORIES.map((c) => c.id)); + patch.categories = [...new Set(patch.categories)].filter((id) => known.has(id)); + } + await saveParserSettings(db, patch); + return parserSettingsView(); + }); + + app.post('/api/admin/parser/run', async (req) => { + superadmin(req); + if (!source) throw badRequest('PARSER_DISABLED'); + return runIngest({ db, source }); + }); + + registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender); + registerClientRoutes(app, db, { member, manager, writable }, config); + registerDepartmentRoutes(app, db, { member, manager, writable }); + registerPipelineRoutes(app, db, { member, manager, writable }); + registerArchiveRoutes(app, db, { member, manager, writable }); + registerAnalyticsRoutes(app, db, { member, manager, writable }); + registerAdminListingRoutes(app, db, superadmin); + registerBillingRoutes(app, db, { owner, superadmin }, config, payments); + registerPayrollRoutes(app, db, { member, manager, writable }); + registerShowcaseRoutes(app, db, { member, manager, writable }, config, fetchImage); + registerBarevRoutes(app, db, config, fetchImage, groupSender); + const fileSender = sendFile ?? (config.telegramBotToken ? telegramFileSender(config.telegramBotToken) : null); + registerSupportRoutes(app, db, superadmin, config, fileSender); + registerBotStartRoutes(app, db, superadmin, fileSender); + + return Object.assign(app, { guards: { auth, member, manager, writable, superadmin, hasModule } }); +} diff --git a/apps/api/src/archive.int.test.ts b/apps/api/src/archive.int.test.ts new file mode 100644 index 0000000..f7a72a8 --- /dev/null +++ b/apps/api/src/archive.int.test.ts @@ -0,0 +1,460 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { events, leadNotifications, leads, listings, sessions, users } from './db/schema.ts'; +import { runIngest, runRemovalCheck, type PagedSource } from './ingest/service.ts'; +import { runLeadJobs } from './leads/service.ts'; +import { LISTAM_CATEGORIES } from './parser/listam-source.ts'; +import type { SourcedListing } from './parser/source.ts'; +import { processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// База бывших клиентов и объектов, отметка «Снято с List.am», повторная публикация, аналитика. + +let t: Awaited>; +let tgId = 9000; +let extId = 2_000_000; +const pages = new Map(); +/** Что «ответит» list.am на проверку объявления: false — снято, undefined — понять не удалось. */ +const alive = new Map(); +const source: PagedSource = { + name: 'fake', + listPage: async (category, page = 1) => (page === 1 ? (pages.get(category.id) ?? []) : []), + inspect: async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) }), + isAlive: async (id) => (alive.has(id) ? alive.get(id) : true), +}; +const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; +const listing = (over: Partial = {}): SourcedListing => { + const id = String(++extId); + return { + externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale', + propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', address: `ул. Абовяна ${id}`, price: 150_000, currency: 'USD', + rooms: 3, area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: [], ...over, + }; +}; + +beforeAll(async () => { + t = await testApp('archive', source); + // Категория должна быть непустой, чтобы первый проход по ней состоялся. + pages.set(SALE.id, [listing({ district: 'Нубарашен' })]); +}); +afterAll(() => t.close()); + +const tg = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); }; +const publish = async (...items: SourcedListing[]) => { + pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]); + const report = await runIngest({ db: t.db, source }); + await flush(); + return report; +}; + +/** Объявление сняли: его нет ни на странице объявления, ни в списке категории. */ +const unpublish = (item: SourcedListing) => { + alive.set(item.externalId, false); + pages.set(SALE.id, (pages.get(SALE.id) ?? []).filter((x) => x.externalId !== item.externalId)); +}; + +interface Member { api: ReturnType; id: number; chat: number } + +const company = async (agents = 1, plan: 'solo' | 'team' = 'team') => { + // Объявления прошлых сценариев «состариваем», чтобы новый фильтр не забрал их из базы. + await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null }); + const [sa] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + const created = await admin.post('/api/admin/companies', { + name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const join = async (name: string, token: string): Promise => { + const api = await t.register(name); + await api.post(`/api/invites/${token}/accept`); + const id = (await api.get('/api/me')).body.user.id as number; + const chat = ++tgId; + await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id)); + return { api, id, chat }; + }; + const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!); + // Solo работает только из Mini App: помечаем сессию как открытую в Telegram. + if (plan === 'solo') await t.db.update(sessions).set({ viaTelegram: true }).where(eq(sessions.userId, boss.id)); + const team: Member[] = []; + if (agents) { + const invite = (await boss.api.post('/api/invites')).body.token as string; + for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite)); + } + await boss.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'] } }); + await runIngest({ db: t.db, source }); + await flush(); + const p = async (name: 'object' | 'client') => { + const info = (await boss.api.get(`/api/pipelines/${name}`)).body; + return { ...info, status: (code: string) => info.statuses.find((s: any) => s.code === code).id as number }; + }; + return { boss, agents: team, id: created.body.id as number, object: await p('object'), client: await p('client') }; +}; + +const poolOf = async (m: Member) => (await m.api.get('/api/leads?tab=pool')).body as any[]; +const deal = { commission: 3000, currency: 'USD', date: new Date().toISOString().slice(0, 10) }; +const day = 86_400_000; + +describe('снятые и повторно опубликованные объявления', () => { + it('раз в сутки объект из CRM проверяется на list.am: снятый получает отметку и дату', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const item = listing(); + await publish(item); + const [lead] = await poolOf(a); + await a.api.post(`/api/leads/${lead.id}/take`); + + // Свежее объявление пока не проверяется. + expect(await runRemovalCheck({ db: t.db, source })).toEqual({ checked: 0, removed: 0 }); + const tomorrow = new Date(Date.now() + day + 60_000); + alive.set(item.externalId, undefined); + expect(await runRemovalCheck({ db: t.db, source, now: tomorrow })).toMatchObject({ removed: 0 }); + expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull(); + // Понять не удалось — повтор через час, а не через сутки. + unpublish(item); + expect(await runRemovalCheck({ db: t.db, source, now: new Date(tomorrow.getTime() + 30 * 60_000) })).toMatchObject({ removed: 0 }); + const later = new Date(tomorrow.getTime() + 61 * 60_000); + expect(await runRemovalCheck({ db: t.db, source, now: later })).toMatchObject({ removed: 1 }); + expect(new Date((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).getTime()).toBe(later.getTime()); + // Снятое больше не проверяется. + expect(await runRemovalCheck({ db: t.db, source, now: new Date(later.getTime() + 2 * day) })).toMatchObject({ removed: 0 }); + }); + + it('свежее объявление первый раз проверяется через 4 часа после находки', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const item = listing(); + await publish(item); + const [lead] = await poolOf(a); + await a.api.post(`/api/leads/${lead.id}/take`); + unpublish(item); + + const hour = 3_600_000; + expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 3 * hour) })).toMatchObject({ removed: 0 }); + expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 4 * hour + 60_000) })).toMatchObject({ removed: 1 }); + expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).not.toBeNull(); + }); + + it('«Объявления больше нет»: отметка у всех компаний, объявление снова в списке — вернулось на сайт', async () => { + const first = await company(1); + const second = await company(1); + const [a] = first.agents as [Member]; + const [b] = second.agents as [Member]; + const item = listing(); + await publish(item); + const [lead] = await poolOf(a); + const [other] = await poolOf(b); + + // Отметить можно и объект из пула. + expect((await a.api.post(`/api/leads/${lead.id}/removed`)).status).toBe(200); + const card = (await a.api.get(`/api/leads/${lead.id}`)).body; + expect(card.removedAt).not.toBeNull(); + expect(card.events.map((e: any) => e.type)).toContain('listing_removed'); + expect((await b.api.get(`/api/leads/${other.id}`)).body.removedAt).not.toBeNull(); + + // Ошибочная отметка: объявление всё ещё в списке list.am, и следующий проход парсера её снимает. + await runIngest({ db: t.db, source }); + expect((await a.api.get(`/api/leads/${lead.id}`)).body.removedAt).toBeNull(); + + // У объекта, заведённого вручную, объявления нет. + const manual = (await a.api.post('/api/leads', { title: 'Дом в Гарни' })).body.id; + expect((await a.api.post(`/api/leads/${manual}/removed`)).status).toBe(400); + }); + + it('снятое объявление: при настройке компании открытые лиды сами уходят в «Отказ», у других — без изменений', async () => { + const on = await company(1); + const off = await company(1); + expect((await on.boss.api.patch('/api/company', { autoRejectRemoved: true })).status).toBe(200); + const [a] = on.agents as [Member]; + const [b] = off.agents as [Member]; + const taken = listing(); + const pooled = listing(); + await publish(taken, pooled); + const leadOf = async (m: Member, item: SourcedListing) => (await poolOf(m)).find((l) => l.url === item.sourceUrl); + const mine = await leadOf(a, taken); + const inPool = await leadOf(a, pooled); + const other = await leadOf(b, taken); + await a.api.post(`/api/leads/${mine.id}/take`); + + // Проверка list.am увидела, что объявление сняли. + unpublish(taken); + expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * 3_600_000) })).toMatchObject({ removed: 1 }); + const card = (await a.api.get(`/api/leads/${mine.id}`)).body; + expect(card.statusId).toBe(on.object.status('rejected')); + expect(on.object.reasons.find((r: any) => r.id === card.rejectReasonId)).toMatchObject({ code: 'gone', name: 'Уже продано или сдано' }); + expect(card.events.some((e: any) => e.type === 'status' && e.text === 'Объявление снято с List.am')).toBe(true); + expect((await b.api.get(`/api/leads/${other.id}`)).body.statusId).toBe(off.object.status('new')); + + // Отметка агента «Объявления больше нет» — лид из пула тоже уходит в «Отказ» и пропадает из пула. + expect((await a.api.post(`/api/leads/${inPool.id}/removed`)).status).toBe(200); + expect((await a.api.get(`/api/leads/${inPool.id}`)).body.statusId).toBe(on.object.status('rejected')); + expect((await poolOf(a)).map((l) => l.id)).not.toContain(inPool.id); + await on.boss.api.patch('/api/company', { autoRejectRemoved: false }); + }); + + it('правка объекта: ответственный и руководитель меняют данные карточки, общая база не меняется', async () => { + const c = await company(2); + const [a, b] = c.agents as [Member, Member]; + const item = listing(); + await publish(item); + const [lead] = await poolOf(a); + + // Объект в пуле агент не правит — сначала «Беру». + expect((await a.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(403); + await a.api.post(`/api/leads/${lead.id}/take`); + // Чужой объект другой агент даже не видит. + expect((await b.api.patch(`/api/leads/${lead.id}`, { price: 900 })).status).toBe(404); + + expect((await a.api.patch(`/api/leads/${lead.id}`, { title: '2-комн. квартира', price: 900, rooms: '', currency: 'AMD' })).status).toBe(200); + const card = (await a.api.get(`/api/leads/${lead.id}`)).body; + expect(card).toMatchObject({ title: '2-комн. квартира', price: 900, rooms: null, currency: 'AMD', area: 80, district: 'Кентрон' }); + expect(card.events.filter((e: any) => e.type === 'edited')).toHaveLength(1); + // Текст для клиента собран из новых данных. + expect(card.clientText).not.toContain('Комнат'); + + // Повтор тех же значений историю не засоряет. + await a.api.patch(`/api/leads/${lead.id}`, { price: 900 }); + expect((await a.api.get(`/api/leads/${lead.id}`)).body.events.filter((e: any) => e.type === 'edited')).toHaveLength(1); + + expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { floor: 5 })).status).toBe(200); + expect((await c.boss.api.patch(`/api/leads/${lead.id}`, { title: '' })).status).toBe(400); + expect((await c.boss.api.patch(`/api/leads/${lead.id}`, {})).status).toBe(400); + + const [base] = await t.db.select().from(listings).where(eq(listings.externalId, item.externalId)); + expect(base).toMatchObject({ title: '3-комн. квартира', price: 150_000, rooms: 3, floor: 4 }); + }); + + it('снятое объявление опубликовано снова под тем же номером: новый лид со ссылкой на прошлый', async () => { + const closed = await company(1); + const open = await company(1); + const [a] = closed.agents as [Member]; + const [b] = open.agents as [Member]; + const item = listing(); + await publish(item); + const first = (await poolOf(a))[0]; + await a.api.post(`/api/leads/${first.id}/take`); + await a.api.post(`/api/leads/${first.id}/status`, { statusId: closed.object.status('rejected'), rejectReasonId: closed.object.reasons[1].id }); + const kept = (await poolOf(b))[0]; + await b.api.post(`/api/leads/${kept.id}/take`); + + unpublish(item); + const removedAt = new Date(Date.now() + day + 60_000); + await runRemovalCheck({ db: t.db, source, now: removedAt }); + // Пока объявление снято, повторный проход парсера ничего не создаёт. + expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 }); + + const before = tg.inbox(a.chat).length; + alive.delete(item.externalId); + const report = await publish(item); + // Новый лид получает только компания, у которой прошлый закрыт. + expect(report.leads).toBe(1); + expect(await poolOf(b)).toHaveLength(0); + const again = (await poolOf(a))[0]; + expect(again.id).not.toBe(first.id); + expect(tg.inbox(a.chat).length).toBe(before + 1); + const card = (await a.api.get(`/api/leads/${again.id}`)).body; + expect(card.removedAt).toBeNull(); + expect(card.previous).toMatchObject({ id: first.id, assigneeName: 'Агент 1', status: 'Отказ' }); + expect(new Date(card.previous.removedAt).getTime()).toBe(removedAt.getTime()); + // Следующие проходы лид не дублируют. + expect(await runIngest({ db: t.db, source })).toMatchObject({ leads: 0 }); + expect(await poolOf(a)).toHaveLength(1); + }); + + it('тот же объект под новым номером: совпали адрес и характеристики, прошлое объявление снято', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const old = listing({ address: 'ул. Туманяна 12' }); + await publish(old); + const first = (await poolOf(a))[0]; + await a.api.post(`/api/leads/${first.id}/take`); + + // Пока прошлое объявление на сайте, совпадение адреса ничего не значит (соседняя квартира). + await publish(listing({ address: 'ул. Туманяна 12' })); + const neighbour = (await poolOf(a))[0]; + expect((await a.api.get(`/api/leads/${neighbour.id}`)).body.previous).toBeNull(); + await a.api.post(`/api/leads/${neighbour.id}/take`); + + unpublish(old); + await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + day + 60_000) }); + await publish(listing({ address: ' УЛ. Туманяна 12 ', price: 140_000 }), listing({ address: 'ул. Туманяна 12', rooms: 2 })); + const pool = await poolOf(a); + expect(pool).toHaveLength(2); + const cards = await Promise.all(pool.map(async (l) => (await a.api.get(`/api/leads/${l.id}`)).body)); + expect(cards.find((x) => x.rooms === 3).previous).toMatchObject({ id: first.id, status: 'Взят в работу' }); + expect(cards.find((x) => x.rooms === 2).previous).toBeNull(); + }); +}); + +describe('база бывших клиентов и объектов', () => { + it('закрытые сделкой и отказом объекты и клиенты попадают в базу; Solo базы и аналитики не видит', async () => { + const c = await company(2); + const [a1, a2] = c.agents as [Member, Member]; + const mk = async (m: Member, over: object) => (await m.api.post('/api/leads', { title: 'Дом в Арабкире', district: 'Арабкир', price: 300_000, ...over })).body.id as number; + const sold = await mk(a1, { phone: '091 111 222', authorName: 'Гагик' }); + const lost = await mk(a1, { phone: '091 111 222', title: 'Квартира Гагика' }); + const working = await mk(a1, { title: 'В работе' }); + const foreign = await mk(a2, { title: 'Чужой', phone: '077 333 444' }); + const buyer = (await a1.api.post('/api/clients', { name: 'Анна', phone: '055 000 111', kind: 'buyer' })).body.id as number; + await a1.api.post(`/api/clients/${buyer}/leads`, { leadId: sold }); + await a1.api.post(`/api/leads/${sold}/status`, { statusId: c.object.status('deal'), deal: { ...deal, clientId: buyer } }); + await a1.api.post(`/api/leads/${lost}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[2].id }); + await a2.api.post(`/api/leads/${foreign}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[1].id }); + + const mine = (await a1.api.get('/api/archive/leads')).body; + expect(mine.map((l: any) => l.id).sort()).toEqual([sold, lost].sort()); + expect(mine.find((l: any) => l.id === sold)).toMatchObject({ result: 'deal', commission: 3000, dealCurrency: 'USD', phone: '+37491111222' }); + expect(mine.find((l: any) => l.id === lost)).toMatchObject({ result: 'rejected', reason: c.object.reasons[2].name, removedAt: null }); + expect(mine.some((l: any) => l.id === working)).toBe(false); + expect((await a1.api.get('/api/archive/leads?result=deal')).body).toHaveLength(1); + expect((await a1.api.get('/api/archive/leads?q=111222')).body).toHaveLength(2); + expect((await c.boss.api.get('/api/archive/leads')).body).toHaveLength(3); + expect((await c.boss.api.get(`/api/archive/leads?assigneeId=${a2.id}`)).body).toMatchObject([{ id: foreign }]); + + // Клиенты: только закрытые покупатели и арендаторы, собственники закрытых объектов сюда не попадают. + const contacts = (await a1.api.get('/api/archive/contacts')).body; + expect(contacts).toHaveLength(1); + expect(contacts[0]).toMatchObject({ type: 'buyer', name: 'Анна', phone: '+37455000111', result: 'deal', objects: [{ id: sold }] }); + expect((await c.boss.api.get('/api/archive/contacts')).body).toHaveLength(1); + expect((await a1.api.get('/api/archive/contacts?q=Анна')).body).toHaveLength(1); + + const solo = await company(0, 'solo'); + expect((await solo.boss.api.get('/api/archive/leads')).body.code).toBe('MODULE_NOT_AVAILABLE'); + expect((await solo.boss.api.get('/api/archive/contacts')).status).toBe(403); + expect((await solo.boss.api.get(`/api/analytics?from=${new Date(0).toISOString()}&to=${new Date().toISOString()}`)).body.code).toBe('MODULE_NOT_AVAILABLE'); + }); +}); + +describe('суперадмин: общая база объявлений', () => { + it('суперадмин видит все объявления с фильтрами и кому они ушли лидом; руководителю раздел закрыт', async () => { + const c = await company(1); + const hit = listing({ title: 'Особняк на Туманяна', price: 777_000 }); + const miss = listing({ district: 'Нубарашен', isAgency: true, title: 'Склад' }); + await publish(hit, miss); + const [sa] = await t.db.insert(users).values({ name: 'Владелец', role: 'superadmin', email: `own${Math.random()}@test.am`, passwordHash: 'x' }).returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + + const all = (await admin.get('/api/admin/listings')).body; + expect(all.total).toBeGreaterThanOrEqual(2); + expect(all.items.length).toBe(Math.min(all.total, 50)); + const found = (await admin.get(`/api/admin/listings?q=${hit.externalId}`)).body; + expect(found).toMatchObject({ total: 1, items: [{ title: 'Особняк на Туманяна', price: 777_000, active: true }] }); + // Лид получили все компании с подходящим фильтром, включая компании прошлых сценариев. + expect(found.items[0].leads).toBeGreaterThanOrEqual(1); + expect((await admin.get(`/api/admin/listings?q=${encodeURIComponent('особняк')}&leads=no`)).body.total).toBe(0); + const agencies = (await admin.get('/api/admin/listings?author=agency&leads=no')).body.items; + expect(agencies.map((l: any) => l.externalId)).toContain(miss.externalId); + expect((await admin.get('/api/admin/listings?priceFrom=700000&priceTo=800000')).body.items.map((l: any) => l.externalId)).toEqual([hit.externalId]); + + const card = (await admin.get(`/api/admin/listings/${found.items[0].id}`)).body; + expect(card.leads).toHaveLength(found.items[0].leads); + expect(card.leads[0]).toMatchObject({ company: 'Агентство', status: 'Новый', assignee: null }); + expect((await c.boss.api.get('/api/admin/listings')).status).toBe(403); + expect((await c.boss.api.get(`/api/admin/listings/${found.items[0].id}`)).status).toBe(403); + + // Настройки парсера: ключ ридера наружу не отдаётся; правка одного поля не трогает остальные. + expect((await c.boss.api.get('/api/admin/parser/settings')).status).toBe(403); + const before = (await admin.get('/api/admin/parser/settings')).body; + expect(before).toMatchObject({ enabled: true, freshDays: 3, pauseSec: 5, maxPages: 3, removalCheck: true, jinaKey: { source: 'none', tail: null } }); + expect(before.categories).toHaveLength(10); + const saved = (await admin.patch('/api/admin/parser/settings', { jinaApiKey: 'jina_secret_1234', pauseSec: 30 })).body; + expect(saved).toMatchObject({ pauseSec: 30, freshDays: 3, jinaKey: { source: 'settings', tail: '1234' } }); + expect(JSON.stringify(saved)).not.toContain('jina_secret'); + expect((await admin.patch('/api/admin/parser/settings', { freshDays: 0 })).status).toBe(400); + + // Выключенная категория не читается; срок свежести действует на блок «Топ» — его порядок ротируется. + await admin.patch('/api/admin/parser/settings', { categories: [56], freshDays: 1 }); + const skipped = listing({ promoted: true }); + expect(await publish(skipped)).toMatchObject({ categories: 1, foundNew: 0 }); + await admin.patch('/api/admin/parser/settings', { categories: before.categories }); + source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 3 * 86_400_000) }); + expect(await runIngest({ db: t.db, source })).toMatchObject({ categories: 10, foundNew: 0 }); + source.inspect = async (card) => ({ ...card, publishedAt: new Date(Date.now() - 40_000) }); + await admin.patch('/api/admin/parser/settings', { freshDays: 3, removalCheck: false }); + // Проверка снятых выключена — ничего не проверяется. + expect(await runRemovalCheck({ db: t.db, source, now: new Date(Date.now() + 5 * day) })).toEqual({ checked: 0, removed: 0 }); + await admin.patch('/api/admin/parser/settings', { removalCheck: true, jinaApiKey: '' }); + expect((await admin.get('/api/admin/parser/settings')).body.jinaKey).toEqual({ source: 'none', tail: null }); + }); +}); + +describe('аналитика по сотрудникам', () => { + const range = () => `from=${new Date(Date.now() - day).toISOString()}&to=${new Date(Date.now() + 3 * day).toISOString()}`; + + it('воронка объекта: взято, скорость реакции, возвраты в пул, конверсия, сделки, причины отказа', async () => { + const c = await company(2); + const [a1, a2] = c.agents as [Member, Member]; + const st = c.object.statuses.map((s: any) => s.id) as number[]; // new, taken, reached, meeting, contract, sale, deal, rejected + await publish(listing(), listing(), listing(), listing()); + const pool = await poolOf(a1); + expect(pool).toHaveLength(4); + const [l1, l2, l3, l4] = pool.map((l) => l.id) as [number, number, number, number]; + + // Агент 1 берёт три лида: один доводит до сделки, один — до встречи и отказа, один теряет по таймеру. + for (const id of [l1, l2, l3]) await a1.api.post(`/api/leads/${id}/take`); + // Скорость реакции: уведомление пришло за 60, 120 и 600 секунд до «Беру» → медиана 120. + for (const [id, sec] of [[l1, 60], [l2, 120], [l3, 600]] as const) { + const [taken] = await t.db.select().from(events).where(eq(events.leadId, id)).then((r) => r.filter((e) => e.type === 'taken')); + await t.db.update(leadNotifications).set({ sentAt: new Date(taken!.at.getTime() - sec * 1000) }).where(eq(leadNotifications.leadId, id)); + } + await a1.api.post(`/api/leads/${l1}/status`, { statusId: st[4] }); + await a1.api.post(`/api/leads/${l1}/status`, { statusId: c.object.status('deal'), deal }); + await a1.api.post(`/api/leads/${l2}/status`, { statusId: st[3] }); + await a1.api.post(`/api/leads/${l2}/status`, { statusId: c.object.status('rejected'), rejectReasonId: c.object.reasons[4].id }); + await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 25 * 3_600_000) }).where(eq(leads.id, l3)); + expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 }); + // Агент 2 подбирает потерянный лид и доводит до «Дозвонился»; руководитель назначает ему четвёртый. + await a2.api.post(`/api/leads/${l3}/take`); + await a2.api.post(`/api/leads/${l3}/status`, { statusId: st[2] }); + await c.boss.api.post(`/api/leads/${l4}/assign`, { userId: a2.id }); + + const r = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body; + expect(r.statuses.map((s: any) => s.id)).toEqual(st); + expect(r.summary).toMatchObject({ created: 4, taken: 4, open: 0, cohort: 4 }); + // Воронка целиком: все 4 взяты; до «Дозвонился» и дальше дошли 3; до встречи — 2; договор и сделка — 1; отказ — 1. + expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([4, 4, 3, 2, 1, 1, 1, 1]); + + const row = (m: Member) => r.agents.find((x: any) => x.userId === m.id); + expect(row(a1)).toMatchObject({ taken: 3, reactionSec: 120, poolReturns: 1, cohort: 3, deals: 1, commission: { USD: 3000 } }); + // Лид, потерянный агентом 1, у него остался на этапе «Взят»; дальнейшее — заслуга агента 2. + expect(st.map((id) => row(a1).reached[id] ?? 0)).toEqual([3, 3, 2, 2, 1, 1, 1, 1]); + expect(row(a1).reasons).toEqual({ [c.object.reasons[4].id]: 1 }); + expect(row(a2)).toMatchObject({ taken: 2, poolReturns: 0, cohort: 2, deals: 0, commission: {} }); + expect(st.map((id) => row(a2).reached[id] ?? 0)).toEqual([2, 2, 1, 0, 0, 0, 0, 0]); + expect(row(c.boss)).toMatchObject({ taken: 0, cohort: 0 }); + expect(r.totals).toMatchObject({ taken: 5, poolReturns: 1, deals: 1, commission: { USD: 3000 } }); + + // Прошлый период пуст; агент аналитику не видит. + const past = (await c.boss.api.get(`/api/analytics?from=${new Date(Date.now() - 30 * day).toISOString()}&to=${new Date(Date.now() - 20 * day).toISOString()}`)).body; + expect(past.summary).toMatchObject({ created: 0, taken: 0 }); + expect(past.totals.deals).toBe(0); + expect((await a1.api.get(`/api/analytics?${range()}`)).status).toBe(403); + }); + + it('воронка клиента: новые запросы, показы, конверсия, сделки, причины отказа', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const st = c.client.statuses.map((s: any) => s.id) as number[]; // new, qualification, selection, showing, negotiation, deal, rejected + const mk = async (name: string) => (await a.api.post('/api/clients', { name, kind: 'buyer' })).body.id as number; + const [won, lost] = [await mk('Анна'), await mk('Давид'), await mk('Мария')]; + const leadId = (await a.api.post('/api/leads', { title: 'Квартира' })).body.id as number; + await a.api.post(`/api/clients/${won}/leads`, { leadId, state: 'shown' }); + await a.api.post(`/api/clients/${won}/status`, { statusId: st[3] }); + await a.api.post(`/api/clients/${won}/status`, { statusId: c.client.status('deal'), deal: { ...deal, commission: 2000, leadId } }); + await a.api.post(`/api/clients/${lost}/status`, { statusId: st[1] }); + await a.api.post(`/api/clients/${lost}/status`, { statusId: c.client.status('rejected'), rejectReasonId: c.client.reasons[0].id }); + + const r = (await c.boss.api.get(`/api/analytics?pipeline=client&${range()}`)).body; + expect(r.summary).toMatchObject({ created: 3, open: 1, cohort: 3 }); + expect(st.map((id) => r.summary.funnel[id] ?? 0)).toEqual([3, 2, 1, 1, 1, 1, 1]); + const row = r.agents.find((x: any) => x.userId === a.id); + expect(row).toMatchObject({ taken: 3, showings: 1, cohort: 3, deals: 1, commission: { USD: 2000 }, reasons: { [c.client.reasons[0].id]: 1 } }); + // Парная сделка одна: в аналитике объектов та же комиссия, без удвоения. + const objects = (await c.boss.api.get(`/api/analytics?pipeline=object&${range()}`)).body; + expect(objects.totals).toMatchObject({ deals: 1, commission: { USD: 2000 } }); + }); +}); diff --git a/apps/api/src/archive/routes.ts b/apps/api/src/archive/routes.ts new file mode 100644 index 0000000..235948c --- /dev/null +++ b/apps/api/src/archive/routes.ts @@ -0,0 +1,152 @@ +import { hasModule, type Plan } from '@lr/shared'; +import { and, desc, eq, gte, ilike, inArray, lte, not, or, sql, type SQL } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { clientLeads, clients, deals, leads, listings, pipelineStatuses, rejectReasons, users } from '../db/schema.ts'; +import type { Guards } from '../leads/routes.ts'; +import { forbidden } from '../lib/errors.ts'; +import { departmentScope, inDepartment, listingLive, priceRange } from '../leads/service.ts'; +import { loadBillingSettings } from '../billing/service.ts'; + +// База бывших клиентов и объектов (тарифы Team и Agency): всё, что закрыто сделкой или отказом, и объекты, +// снятые с List.am. Попадает сюда автоматически — это те же лиды и клиенты, отобранные по итоговому статусу. +// Сотрудник видит базу своего отдела, руководитель — всех отделов. + +const CLOSED = ['deal', 'rejected']; + +export function registerArchiveRoutes(app: FastifyInstance, db: Db, g: Guards) { + const guard = (req: Parameters[0]) => { + const ctx = g.member(req); + if (!hasModule(ctx.company.plan as Plan, 'archive')) throw forbidden('MODULE_NOT_AVAILABLE'); + return ctx; + }; + const Query = z.object({ + q: z.string().trim().max(100).optional(), + /** removed — объекты, снятые с List.am, в любом статусе. */ + result: z.enum(['deal', 'rejected', 'removed']).optional(), + assigneeId: z.coerce.number().int().optional(), + district: z.string().trim().max(60).optional(), + priceFrom: z.coerce.number().nonnegative().optional(), + priceTo: z.coerce.number().nonnegative().optional(), + priceCurrency: z.enum(['AMD', 'USD']).default('USD'), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + }); + const like = (q: string) => `%${q.replace(/[%_\\]/g, '\\$&')}%`; + + /** Закрытые объекты. Агент видит свои, руководитель и агенты при открытой видимости — все. */ + app.get('/api/archive/leads', async (req) => { + const ctx = guard(req); + const q = Query.parse(req.query); + const isManager = ctx.role === 'manager'; + const seeAll = isManager || ctx.company.settings.agentsSeeAll; + const removed = not(listingLive); + const where: (SQL | undefined)[] = [ + eq(leads.companyId, ctx.company.id), + q.result === 'removed' ? removed : q.result ? inArray(pipelineStatuses.code, [q.result]) : or(inArray(pipelineStatuses.code, CLOSED), removed), + departmentScope(ctx, leads.departmentId), + seeAll ? undefined : eq(leads.assigneeId, ctx.user.id), + ]; + if (q.assigneeId && seeAll) where.push(eq(leads.assigneeId, q.assigneeId)); + if (q.district) where.push(ilike(leads.district, q.district)); + if (q.priceFrom !== undefined || q.priceTo !== undefined) { + const { rates } = await loadBillingSettings(db); + where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo)); + } + if (q.from) where.push(gte(leads.statusChangedAt, q.from)); + if (q.to) where.push(lte(leads.statusChangedAt, q.to)); + if (q.q) { + const digits = q.q.replace(/\D/g, ''); + where.push( + or( + ilike(leads.address, like(q.q)), + ilike(leads.title, like(q.q)), + ilike(leads.url, like(q.q)), + digits.length >= 4 + ? and(ilike(leads.phone, `%${digits}%`), isManager ? undefined : eq(leads.assigneeId, ctx.user.id)) + : undefined, + ), + ); + } + const rows = await db + .select({ + lead: leads, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name, + commission: deals.commission, dealCurrency: deals.currency, active: listings.active, removedAt: listings.removedAt, + }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .leftJoin(rejectReasons, eq(rejectReasons.id, leads.rejectReasonId)) + .leftJoin(deals, eq(deals.leadId, leads.id)) + .leftJoin(listings, eq(listings.id, leads.listingId)) + .where(and(...where)) + .orderBy(desc(leads.statusChangedAt)) + .limit(300); + return rows.map(({ lead: l, ...r }) => { + const own = isManager || l.assigneeId === ctx.user.id; + return { + id: l.id, title: l.title, url: l.url, source: l.source, dealType: l.dealType, district: l.district, region: l.region, + address: l.address, price: l.price, currency: l.currency, photo: l.photos[0] ?? null, + authorName: own ? l.authorName : null, phone: own ? l.phone : null, + assigneeId: l.assigneeId, assigneeName: r.assigneeName, closedAt: l.statusChangedAt, + result: r.result, reason: r.reason, commission: r.commission, dealCurrency: r.dealCurrency, + // Отметка «Снято с List.am». + removedAt: r.active === false ? r.removedAt : null, + }; + }); + }); + + /** Клиенты: закрытые покупатели и арендаторы, у каждого — список связанных объектов. */ + app.get('/api/archive/contacts', async (req) => { + const ctx = guard(req); + const q = Query.pick({ q: true, result: true, assigneeId: true }).parse(req.query); + const isManager = ctx.role === 'manager'; + const seeAll = isManager || ctx.company.settings.agentsSeeAll; + const digits = q.q?.replace(/\D/g, '') ?? ''; + const agent = q.assigneeId && seeAll ? q.assigneeId : null; + + const closedClients = await db + .select({ client: clients, result: pipelineStatuses.code, assigneeName: users.name, reason: rejectReasons.name }) + .from(clients) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId)) + .leftJoin(users, eq(users.id, clients.assigneeId)) + .leftJoin(rejectReasons, eq(rejectReasons.id, clients.rejectReasonId)) + .where( + and( + eq(clients.companyId, ctx.company.id), + inArray(pipelineStatuses.code, q.result && q.result !== 'removed' ? [q.result] : CLOSED), + isManager ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)), + seeAll ? undefined : eq(clients.assigneeId, ctx.user.id), + agent ? eq(clients.assigneeId, agent) : undefined, + q.q + ? or( + ilike(clients.name, like(q.q)), + digits.length >= 4 + ? and(ilike(clients.phone, `%${digits}%`), isManager ? undefined : eq(clients.assigneeId, ctx.user.id)) + : undefined, + ) + : undefined, + ), + ) + .orderBy(desc(clients.statusChangedAt)) + .limit(300); + const ids = closedClients.map((r) => r.client.id); + const links = ids.length + ? await db + .select({ clientId: clientLeads.clientId, id: leads.id, title: leads.title, state: clientLeads.state }) + .from(clientLeads) + .innerJoin(leads, eq(leads.id, clientLeads.leadId)) + .where(inArray(clientLeads.clientId, ids)) + : []; + + return closedClients.map(({ client: c, result, assigneeName, reason }) => { + const own = isManager || c.assigneeId === ctx.user.id; + return { + type: c.kind, key: `c${c.id}`, clientId: c.id, name: c.name, phone: own ? c.phone : null, result, reason, + closedAt: c.statusChangedAt, assigneeName, + objects: links.filter((l) => l.clientId === c.id).map((l) => ({ id: l.id, title: l.title, state: l.state })), + }; + }); + }); +} diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts new file mode 100644 index 0000000..1de5100 --- /dev/null +++ b/apps/api/src/auth/password.ts @@ -0,0 +1,18 @@ +import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; + +const scryptAsync = promisify(scrypt) as (pw: string, salt: Buffer, len: number) => Promise; + +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const hash = await scryptAsync(password, salt, 64); + return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`; +} + +export async function verifyPassword(password: string, stored: string | null): Promise { + const [algo, salt, hash] = (stored ?? '').split('$'); + if (algo !== 'scrypt' || !salt || !hash) return false; + const expected = Buffer.from(hash, 'base64'); + const actual = await scryptAsync(password, Buffer.from(salt, 'base64'), expected.length); + return timingSafeEqual(actual, expected); +} diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts new file mode 100644 index 0000000..729f668 --- /dev/null +++ b/apps/api/src/auth/sessions.ts @@ -0,0 +1,33 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { and, eq, gt } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { sessions, users } from '../db/schema.ts'; + +export const SESSION_COOKIE = 'lr_session'; +export const SESSION_DAYS = 30; + +export const newToken = () => randomBytes(32).toString('base64url'); +export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); + +export async function createSession(db: Db, userId: number, viaTelegram = false): Promise { + const token = newToken(); + await db.insert(sessions).values({ + tokenHash: hashToken(token), + userId, + viaTelegram, + expiresAt: new Date(Date.now() + SESSION_DAYS * 86_400_000), + }); + return token; +} + +export async function findSession(db: Db, token: string) { + const [row] = await db + .select({ user: users, actingCompanyId: sessions.actingCompanyId, viaTelegram: sessions.viaTelegram }) + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date()))); + return row ?? null; +} + +export const deleteSession = (db: Db, token: string) => + db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); diff --git a/apps/api/src/auth/telegram.ts b/apps/api/src/auth/telegram.ts new file mode 100644 index 0000000..3fb08ac --- /dev/null +++ b/apps/api/src/auth/telegram.ts @@ -0,0 +1,82 @@ +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +export interface TelegramUser { + id: number; + firstName: string; + lastName?: string; + username?: string; + languageCode?: string; +} + +/** Данные входа старше суток не принимаются. */ +const MAX_AGE_SEC = 86_400; + +const safeEqualHex = (a: string, b: string) => { + const x = Buffer.from(a, 'hex'); + const y = Buffer.from(b, 'hex'); + return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); +}; + +const checkString = (pairs: [string, string][]) => + pairs + .filter(([k]) => k !== 'hash') + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join('\n'); + +const fresh = (authDate: string | undefined, now: number) => { + const t = Number(authDate); + return Number.isFinite(t) && now / 1000 - t <= MAX_AGE_SEC; +}; + +/** Проверка подписи Telegram Login Widget (вход в веб-кабинете). */ +export function verifyLoginWidget( + data: Record, + botToken: string, + now = Date.now(), +): TelegramUser | null { + const pairs = Object.entries(data) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => [k, String(v)] as [string, string]); + const map = new Map(pairs); + const hash = map.get('hash'); + if (!hash) return null; + const secret = createHash('sha256').update(botToken).digest(); + const expected = createHmac('sha256', secret).update(checkString(pairs)).digest('hex'); + if (!safeEqualHex(expected, hash) || !fresh(map.get('auth_date'), now)) return null; + const id = Number(map.get('id')); + if (!Number.isSafeInteger(id)) return null; + return { + id, + firstName: map.get('first_name') ?? '', + lastName: map.get('last_name'), + username: map.get('username'), + }; +} + +/** Проверка initData из Telegram Mini App. */ +export function verifyWebAppInitData( + initData: string, + botToken: string, + now = Date.now(), +): TelegramUser | null { + const params = new URLSearchParams(initData); + const hash = params.get('hash'); + if (!hash) return null; + const secret = createHmac('sha256', 'WebAppData').update(botToken).digest(); + const expected = createHmac('sha256', secret).update(checkString([...params.entries()])).digest('hex'); + if (!safeEqualHex(expected, hash) || !fresh(params.get('auth_date') ?? undefined, now)) return null; + try { + const u = JSON.parse(params.get('user') ?? '') as Record; + if (typeof u.id !== 'number') return null; + return { + id: u.id, + firstName: typeof u.first_name === 'string' ? u.first_name : '', + lastName: typeof u.last_name === 'string' ? u.last_name : undefined, + username: typeof u.username === 'string' ? u.username : undefined, + languageCode: typeof u.language_code === 'string' ? u.language_code : undefined, + }; + } catch { + return null; + } +} diff --git a/apps/api/src/barev.int.test.ts b/apps/api/src/barev.int.test.ts new file mode 100644 index 0000000..73dde39 --- /dev/null +++ b/apps/api/src/barev.int.test.ts @@ -0,0 +1,128 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { normalizeTelegram } from './barev/service.ts'; +import { clients, companies, outbox, users, viewings } from './db/schema.ts'; +import { testApp } from './testing.ts'; + +// Сайт BarevClub: объекты «дозвонились и указали комиссию» → заявка на просмотр → клиент в CRM и сообщение в топик группы. + +let t: Awaited>; +beforeAll(async () => { + t = await testApp('barev'); +}); +afterAll(() => t.close()); + +let keySeq = 0; + +/** Запрос от сервера сайта: с ключом компании. */ +const site = async (key: string, method: 'GET' | 'POST', url: string, body?: object) => { + const res = await t.app.inject({ method, url, payload: body, headers: { 'x-barev-key': key } }); + return { status: res.statusCode, body: res.headers['content-type']?.toString().startsWith('application/json') ? (res.json() as any) : res.rawPayload }; +}; + +const tomorrow = () => new Date(Date.now() + 4 * 3_600_000 + 86_400_000).toISOString().slice(0, 10); + +const setup = async () => { + const [sa] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + const created = await admin.post('/api/admin/companies', { + name: 'AREV', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const boss = await t.register('Руководитель'); + await boss.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`); + const agentApi = await t.register('Арам'); + await agentApi.post(`/api/invites/${(await boss.post('/api/invites')).body.token}/accept`); + const agentId = (await agentApi.get('/api/me')).body.user.id as number; + const id = created.body.id as number; + const key = `barev-test-key-${++keySeq}-0123456789abcdef`; + const [co] = await t.db.select().from(companies).where(eq(companies.id, id)); + await t.db + .update(companies) + .set({ settings: { ...co!.settings, clientHost: 'arev.test', groupBot: { token: 'G:T', chatId: -100500, threadId: 2 }, barevClub: { key, threadId: 442 } } }) + .where(eq(companies.id, id)); + return { id, boss, agentApi, agentId, key }; +}; + +describe('витрина BarevClub', () => { + it('без ключа и с чужим ключом — 401; ключ не виден в кабинете и не стирается сохранением настроек', async () => { + const { boss, key } = await setup(); + expect((await site('', 'GET', '/api/barev/objects')).status).toBe(401); + expect((await site('x'.repeat(30), 'GET', '/api/barev/objects')).status).toBe(401); + expect((await boss.get('/api/me')).body.company.settings.barevClub).toBeUndefined(); + await boss.patch('/api/company', { agentsSeeAll: true }); + expect((await site(key, 'GET', '/api/barev/objects')).status).toBe(200); + }); + + it('показывает только объекты в работе с комиссией; заявка создаёт клиента у агента объекта и пишет в топик', async () => { + const { id: companyId, agentApi, agentId, key } = await setup(); + const withCommission = (await agentApi.post('/api/leads', { title: '2-комн. квартира, ул. Абовяна 5', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD', rooms: 2 })).body.id as number; + const noCommission = (await agentApi.post('/api/leads', { title: 'Дом', dealType: 'rent', price: 500000, currency: 'AMD' })).body.id as number; + await agentApi.post(`/api/leads/${withCommission}/commission`, { rule: { mode: 'percent', value: 50 } }); + + const list = await site(key, 'GET', '/api/barev/objects'); + const ids = (list.body as { id: number }[]).map((o) => o.id); + expect(ids).toContain(withCommission); + expect(ids).not.toContain(noCommission); + const obj = (list.body as any[]).find((o) => o.id === withCommission); + expect(obj).toMatchObject({ district: 'Кентрон', price: 350000, currency: 'AMD', rooms: 2, photos: 0 }); + expect(typeof obj.text).toBe('string'); + // Ни адреса, ни ссылки, ни комиссии наружу. + expect(JSON.stringify(obj)).not.toContain('Абовяна'); + expect(obj).not.toHaveProperty('commission'); + expect(obj).not.toHaveProperty('url'); + + // Без контакта и в прошлый день — нельзя; на объект без комиссии — нельзя. + expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна' })).body.code).toBe('CONTACT_REQUIRED'); + expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: '2020-01-01', time: 'evening', name: 'Анна', phone: '+37491111111' })).body.code).toBe('BAD_DAY'); + expect((await site(key, 'POST', '/api/barev/requests', { leadId: noCommission, day: tomorrow(), time: 'evening', name: 'Анна', phone: '+37491111111' })).status).toBe(404); + expect((await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'не ник' })).body.code).toBe('BAD_TELEGRAM'); + + t.groupSent.length = 0; + const res = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'evening', name: 'Анна', telegram: 'https://t.me/anna_ru' }); + expect(res.status).toBe(200); + const [client] = await t.db.select().from(clients).where(eq(clients.id, res.body.clientId)); + expect(client).toMatchObject({ companyId, name: 'Анна', telegram: '@anna_ru', phone: null, source: 'BarevClub', assigneeId: agentId, kind: 'tenant' }); + const [viewing] = await t.db.select().from(viewings).where(eq(viewings.clientId, client!.id)); + expect(viewing).toMatchObject({ leadId: withCommission, day: tomorrow() }); + expect(viewing!.note).toContain('вечер'); + expect(t.groupSent).toHaveLength(1); + expect(t.groupSent[0]).toMatchObject({ kind: 'text', chatId: -100500, threadId: 442 }); + expect(t.groupSent[0]!.text).toContain('Клиент из BarevClub'); + expect(t.groupSent[0]!.text).toContain('просьба позвонить'); + expect(t.groupSent[0]!.text).toContain('@anna_ru'); + expect(t.groupSent[0]!.text).toContain('Объект ведёт: Арам'); + expect(t.groupSent[0]!.text).toContain(`https://arev.test/clients/${client!.id}`); + + // Тот же человек снова — та же карточка, с телефоном; в группе пометка о повторной заявке. + const again = await site(key, 'POST', '/api/barev/requests', { leadId: withCommission, day: tomorrow(), time: 'morning', name: 'Анна К.', phone: '091 22 33 44', telegram: '@anna_ru' }); + expect(again.body.clientId).toBe(client!.id); + const [updated] = await t.db.select().from(clients).where(eq(clients.id, client!.id)); + expect(updated!.phone).toBeTruthy(); + expect(t.groupSent.at(-1)!.text).toContain('повторная заявка'); + }); + + it('группа недоступна — агенту объекта уходит уведомление в бот', async () => { + const { agentApi, agentId, key } = await setup(); + const lead = (await agentApi.post('/api/leads', { title: 'Студия', dealType: 'rent', price: 200000, currency: 'AMD' })).body.id as number; + await agentApi.post(`/api/leads/${lead}/commission`, { rule: { mode: 'percent', value: 50 } }); + const failing = { album: async () => {}, text: async () => { throw new Error('chat not found'); } }; + const { createViewingRequest, companyByKey } = await import('./barev/service.ts'); + const company = await companyByKey(t.db, key); + await createViewingRequest({ db: t.db, config: t.config, sender: failing }, company, { leadId: lead, day: tomorrow(), time: 'day', name: 'Пётр', phone: '+37499000000' }); + const queued = await t.db.select().from(outbox).where(eq(outbox.userId, agentId)); + expect(queued.some((m) => (m.payload as { key: string }).key === 'viewingBooked')).toBe(true); + }); + + it('ник Telegram понимается в разных видах', () => { + expect(normalizeTelegram('@ivan_p')).toBe('@ivan_p'); + expect(normalizeTelegram('t.me/ivan_p')).toBe('@ivan_p'); + expect(normalizeTelegram('https://t.me/ivan_p/')).toBe('@ivan_p'); + expect(normalizeTelegram('иван')).toBeNull(); + expect(normalizeTelegram('ab')).toBeNull(); + }); +}); diff --git a/apps/api/src/barev/routes.ts b/apps/api/src/barev/routes.ts new file mode 100644 index 0000000..a8072ff --- /dev/null +++ b/apps/api/src/barev/routes.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import type { GroupSender } from '../group/service.ts'; +import type { Config } from '../lib/config.ts'; +import type { ImageFetcher } from '../showcase/media.ts'; +import { companyByKey, createViewingRequest, listObjects, objectPhoto, VIEWING_TIMES, type ViewingTime } from './service.ts'; + +// API для сайта BarevClub. Вход — ключ компании в заголовке X-Barev-Key: его знает только сервер сайта, +// посетителю сайта он не виден. + +export function registerBarevRoutes(app: FastifyInstance, db: Db, config: Config, fetchImage?: ImageFetcher, sender?: GroupSender) { + const company = (req: FastifyRequest) => companyByKey(db, req.headers['x-barev-key'] as string | undefined); + + app.get('/api/barev/objects', async (req) => listObjects(db, await company(req))); + + app.get('/api/barev/objects/:id/photos/:index', async (req, reply) => { + const { id, index } = z.object({ id: z.coerce.number().int().positive(), index: z.coerce.number().int().min(0).max(50) }).parse(req.params); + const photo = await objectPhoto(db, config, await company(req), id, index, fetchImage); + return reply.header('content-type', 'image/jpeg').header('cache-control', 'public, max-age=86400').send(photo); + }); + + app.post('/api/barev/requests', async (req) => { + const co = await company(req); + const body = z + .object({ + leadId: z.number().int().positive(), + day: z.iso.date(), + time: z.enum(Object.keys(VIEWING_TIMES) as [ViewingTime, ...ViewingTime[]]), + name: z.string().trim().min(1).max(80), + phone: z.string().trim().max(40).optional(), + telegram: z.string().trim().max(80).optional(), + }) + .parse(req.body); + return createViewingRequest({ db, config, sender }, co, body); + }); +} diff --git a/apps/api/src/barev/service.ts b/apps/api/src/barev/service.ts new file mode 100644 index 0000000..d4274eb --- /dev/null +++ b/apps/api/src/barev/service.ts @@ -0,0 +1,260 @@ +import { normalizePhone } from '@lr/shared'; +import { and, desc, eq, isNotNull, or, sql } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { clientLeads, clients, companies, events, leads, outbox, pipelineStatuses, users, viewings } from '../db/schema.ts'; +import { telegramGroupSender, type GroupSender } from '../group/service.ts'; +import type { Config } from '../lib/config.ts'; +import { listingLive } from '../leads/service.ts'; +import { AppError, badRequest, notFound } from '../lib/errors.ts'; +import { statusByCode } from '../pipelines/service.ts'; +import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts'; +import { renderShowcaseText } from '../showcase/text.ts'; +import { esc } from '../telegram/texts.ts'; + +// Витрина на сайте BarevClub (barevclub.com → «Найдём квартиру»). +// Сайт по ключу компании берёт её объекты — те же, что бот группы публикует в закрытую группу: +// агент дозвонился (объект в работе) и указал комиссию. Клиент выбирает объект и день просмотра, +// оставляет имя и телефон или Telegram — в CRM появляется клиент «BarevClub», закреплённый за агентом +// объекта, а бот группы пишет об этом в топик заявок. + +export const BAREV_SOURCE = 'BarevClub'; + +/** Сколько фото объекта показываем на сайте (как на странице подборки). */ +const MAX_PHOTOS = 20; +const isListAmPhoto = (url: string) => /^https:\/\/(?:img|s)\.list\.am\//.test(url); + +type Company = typeof companies.$inferSelect; +type Lead = typeof leads.$inferSelect; + +/** Компания по ключу сайта. Ключ сравнивается целиком; пустой или чужой — 401. */ +export async function companyByKey(db: Db, key: string | undefined): Promise { + if (!key || key.length < 24) throw new AppError(401, 'BAD_KEY'); + const [company] = await db.select().from(companies).where(sql`${companies.settings}->'barevClub'->>'key' = ${key}`); + if (!company) throw new AppError(401, 'BAD_KEY'); + return company; +} + +/** Объекты на витрине: в работе (не новый, не сделка, не отказ), с агентом и с комиссией в карточке. */ +async function showcaseLeads(db: Tx, companyId: number, leadId?: number) { + return db + .select({ lead: leads }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .where( + and( + eq(leads.companyId, companyId), + isNotNull(leads.assigneeId), + isNotNull(leads.commission), + sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`, + // Снятый с List.am объект с витрины убирается. + listingLive, + leadId ? eq(leads.id, leadId) : undefined, + ), + ) + .orderBy(desc(leads.takenAt), desc(leads.id)); +} + +/** + * Что видит посетитель сайта. Адреса, телефона собственника, ссылки на объявление и комиссии здесь нет: + * показ идёт через агента. Фото — адреса относительно сайта, сам сайт забирает их по ключу. + */ +export async function listObjects(db: Db, company: Company) { + const rows = await showcaseLeads(db, company.id); + return rows.map(({ lead: l }) => ({ + id: l.id, + /** «Описание для клиента» из карточки объекта. */ + text: l.showcaseText ?? renderShowcaseText(l), + dealType: l.dealType, + propertyType: l.propertyType, + district: l.district, + price: l.price, + currency: l.currency, + rooms: l.rooms, + area: l.area, + floor: l.floor, + floorsTotal: l.floorsTotal, + /** Сколько фото у объекта; само фото — GET …/objects/:id/photos/:index. */ + photos: Math.min(l.photos.length, MAX_PHOTOS), + })); +} + +/** Фото объекта витрины: из объявления — обрезанное, у объекта, заведённого вручную, — как есть. */ +export async function objectPhoto(db: Db, config: Config, company: Company, leadId: number, index: number, fetcher?: ImageFetcher) { + const [row] = await showcaseLeads(db, company.id, leadId); + const url = row?.lead.photos[index]; + if (!url || index >= MAX_PHOTOS) throw notFound(); + if (!isListAmPhoto(url)) { + const res = await fetch(url, { signal: AbortSignal.timeout(20_000) }).catch(() => null); + if (!res?.ok) throw notFound(); + return Buffer.from(await res.arrayBuffer()); + } + return croppedPhoto(config, url, fetcher).catch(() => { + throw notFound(); + }); +} + +export const VIEWING_TIMES = { morning: 'утро, 10:00–13:00', day: 'день, 13:00–17:00', evening: 'вечер, 17:00–20:00' } as const; +export type ViewingTime = keyof typeof VIEWING_TIMES; + +export interface ViewingRequest { + leadId: number; + day: string; + time: ViewingTime; + name: string; + phone?: string; + telegram?: string; +} + +/** «@ivan», «ivan», «t.me/ivan», «https://t.me/ivan» → «@ivan». null — не похоже на ник. */ +export function normalizeTelegram(raw: string): string | null { + const nick = raw.trim().replace(/^(?:https?:\/\/)?(?:t\.me|telegram\.me)\//i, '').replace(/^@/, '').replace(/\/$/, ''); + return /^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(nick) ? `@${nick}` : null; +} + +/** Дата по Еревану (UTC+4). */ +const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10); + +/** «пятница, 24 октября». */ +const humanDay = (day: string) => + new Date(`${day}T12:00:00+04:00`).toLocaleDateString('ru-RU', { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'Asia/Yerevan' }); + +const SYMBOL: Record = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' }; +const priceLine = (price: number | null, currency: string | null) => { + if (!price) return null; + const n = Math.round(price).toLocaleString('ru-RU').replace(/[  ]/g, ' '); + const c = currency ?? 'AMD'; + return c === 'AMD' || c === 'RUB' ? `${n} ${SYMBOL[c]}` : `${SYMBOL[c] ?? c}${n}`; +}; + +/** Адрес кабинета компании: свой домен, если задан, иначе адрес сервиса. */ +const cabinetUrl = (config: Config, company: Company) => + company.settings.clientHost ? `https://${company.settings.clientHost}` : config.appUrl; + +/** Сообщение в топик заявок группы. */ +export function requestText(input: { client: { id: number; name: string; phone: string | null; telegram: string | null }; lead: Lead; day: string; time: ViewingTime; agentName: string | null; url: string; repeat: boolean }) { + const { client, lead, day, time, agentName, url, repeat } = input; + const object = [lead.title, priceLine(lead.price, lead.currency), lead.district].filter(Boolean).join(' · '); + return [ + `🆕 Клиент из BarevClub${repeat ? ' (повторная заявка)' : ''} — просьба позвонить`, + '', + `👤 ${esc(client.name)}`, + client.phone ? `📞 ${esc(client.phone)}` : null, + client.telegram ? `✈️ ${esc(client.telegram)}` : null, + `🏠 ${esc(object)}`, + `📅 Просмотр: ${esc(humanDay(day))}, ${VIEWING_TIMES[time]}`, + `🧑‍💼 Объект ведёт: ${esc(agentName ?? '—')}`, + `🔗 ${esc(url)}`, + ] + .filter((line) => line !== null) + .join('\n'); +} + +/** + * Заявка с сайта: клиент (новый или уже приходивший с тем же телефоном или Telegram), объект в его + * подборке, запись на показ и уведомление в топик заявок группы. Не дошло до группы — агенту объекта + * в бот уходит обычное уведомление о записи на показ. + */ +export async function createViewingRequest( + { db, config, sender = telegramGroupSender, now = new Date() }: { db: Db; config: Config; sender?: GroupSender; now?: Date }, + company: Company, + input: ViewingRequest, +) { + const phone = input.phone?.trim() ? (normalizePhone(input.phone) ?? input.phone.trim()) : null; + if (phone && phone.replace(/\D/g, '').length < 6) throw badRequest('BAD_PHONE'); + const telegram = input.telegram?.trim() ? normalizeTelegram(input.telegram) : null; + if (input.telegram?.trim() && !telegram) throw badRequest('BAD_TELEGRAM'); + if (!phone && !telegram) throw badRequest('CONTACT_REQUIRED'); + const today = ymd(now); + const last = ymd(new Date(now.getTime() + 14 * 86_400_000)); + if (input.day < today || input.day > last) throw badRequest('BAD_DAY'); + const name = input.name.trim().slice(0, 80); + + const result = await db.transaction(async (tx) => { + const [row] = await showcaseLeads(tx, company.id, input.leadId); + if (!row) throw notFound('OBJECT_NOT_FOUND'); + const lead = row.lead; + const assigneeId = lead.assigneeId!; + // Тот же человек уже оставлял заявку на сайте — дописываем к его карточке, а не заводим вторую. + const [earlier] = await tx + .select() + .from(clients) + .where( + and( + eq(clients.companyId, company.id), + eq(clients.source, BAREV_SOURCE), + or(phone ? eq(clients.phone, phone) : undefined, telegram ? eq(clients.telegram, telegram) : undefined), + ), + ) + .orderBy(desc(clients.id)) + .limit(1); + let client = earlier; + if (client) { + await tx + .update(clients) + .set({ name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram }) + .where(eq(clients.id, client.id)); + client = { ...client, name, phone: phone ?? client.phone, telegram: telegram ?? client.telegram }; + } else { + const fresh = await statusByCode(tx, company.id, 'new', 'client'); + [client] = await tx + .insert(clients) + .values({ + companyId: company.id, + name, + phone, + telegram, + kind: lead.dealType === 'sale' ? 'buyer' : 'tenant', + request: {}, + source: BAREV_SOURCE, + assigneeId, + statusId: fresh.id, + nextActionAt: now, + nextActionNote: 'Позвонить: заявка с сайта BarevClub', + }) + .returning(); + await tx.insert(events).values({ companyId: company.id, clientId: client!.id, userId: null, type: 'created' }); + } + await tx.insert(clientLeads).values({ clientId: client!.id, leadId: lead.id, state: 'offered' }).onConflictDoNothing(); + const note = `${VIEWING_TIMES[input.time]} · заявка с сайта BarevClub`; + await tx + .insert(viewings) + .values({ companyId: company.id, clientId: client!.id, leadId: lead.id, day: input.day, note }) + .onConflictDoUpdate({ target: [viewings.clientId, viewings.leadId], set: { day: input.day, note } }); + await tx.insert(events).values([ + { companyId: company.id, clientId: client!.id, userId: null, type: 'viewing', newValue: lead.title, text: input.day }, + { companyId: company.id, leadId: lead.id, userId: null, type: 'client_viewing', newValue: client!.name, text: input.day }, + ]); + await tx.update(companies).set({ lastActivityAt: now }).where(eq(companies.id, company.id)); + const [agent] = await tx.select({ name: users.name }).from(users).where(eq(users.id, assigneeId)); + return { client: client!, lead, assigneeId, agentName: agent?.name ?? null, repeat: Boolean(earlier) }; + }); + + const text = requestText({ + client: result.client, + lead: result.lead, + day: input.day, + time: input.time, + agentName: result.agentName, + url: `${cabinetUrl(config, company)}/clients/${result.client.id}`, + repeat: result.repeat, + }); + const bot = company.settings.groupBot; + let notified = false; + if (bot?.chatId) { + try { + await sender.text({ token: bot.token, chatId: bot.chatId, threadId: company.settings.barevClub?.threadId }, text); + notified = true; + } catch (error) { + console.error(`[barev] заявка клиента ${result.client.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (!notified) { + await db.insert(outbox).values({ + kind: 'text', + userId: result.assigneeId, + leadId: result.lead.id, + payload: { key: 'viewingBooked', args: [result.client.name, result.lead.title, input.day, `${VIEWING_TIMES[input.time]} · BarevClub`] }, + }); + } + return { ok: true, clientId: result.client.id }; +} diff --git a/apps/api/src/billing.int.test.ts b/apps/api/src/billing.int.test.ts new file mode 100644 index 0000000..a785bed --- /dev/null +++ b/apps/api/src/billing.int.test.ts @@ -0,0 +1,398 @@ +import { and, eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import type { PaymentProvider } from './billing/provider.ts'; +import { addDays, addMonths, endOfDay, runBillingJobs, ymd } from './billing/service.ts'; +import { companies, deals, invoices, payments, users } from './db/schema.ts'; +import { processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// Биллинг, сообщения суперадмина, мастер настройки. + +let t: Awaited>; +let tgId = 5000; + +/** Платёжный провайдер-заглушка: подпись — заголовок x-secret. */ +const provider: PaymentProvider = { + name: 'fakepay', + createPayment: async (r) => ({ externalId: `pay-${r.invoiceId}`, url: `https://pay.test/${r.invoiceId}` }), + parseWebhook: async (headers, body) => (headers['x-secret'] === 'ok' ? (body as { externalId: string; status: 'succeeded' | 'failed' }) : null), +}; + +beforeAll(async () => { t = await testApp('billing', null, provider); }); +afterAll(() => t.close()); + +const tg = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); }; + +const superadmin = async () => { + const [u] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + return t.client(await createSession(t.db, u!.id)); +}; + +const join = async (name: string, token: string) => { + const api = await t.register(name); + await api.post(`/api/invites/${token}/accept`); + const id = (await api.get('/api/me')).body.user.id as number; + const chat = ++tgId; + await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id)); + return { api, id, chat }; +}; + +/** Компания, включённая суперадмином; подписка заканчивается через `days` дней. */ +const company = async (plan: 'solo' | 'team' | 'agency' = 'team', billingModel: 'fixed' | 'percent' = 'fixed', days = 30) => { + const sa = await superadmin(); + const created = await sa.post('/api/admin/companies', { + name: `Компания ${plan}`, plan, billingModel, subscriptionEndsAt: endOfDay(addDays(ymd(new Date()), days)).toISOString(), + }); + expect(created.status).toBe(200); + const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!); + return { sa, boss, id: created.body.id as number }; +}; + +const row = async (id: number) => (await t.db.select().from(companies).where(eq(companies.id, id)))[0]!; +const invoicesOf = async (id: number) => t.db.select().from(invoices).where(eq(invoices.companyId, id)).orderBy(invoices.id); +/** Полдень по Еревану: напоминания уходят только днём. */ +const noon = (day: string) => new Date(`${day}T12:00:00+04:00`); +const today = () => ymd(new Date()); + +describe('даты', () => { + it('месяц прибавляется с учётом короткого февраля', () => { + expect(addMonths('2027-01-31', 1)).toBe('2027-02-28'); + expect(addMonths('2026-12-15', 1)).toBe('2027-01-15'); + expect(addDays('2026-12-31', 1)).toBe('2027-01-01'); + }); +}); + +describe('фикс', () => { + it('самостоятельное подключение: счета за установку и первый месяц, доступ открывается после оплаты', async () => { + const u = await t.register('Сам'); + await u.post('/api/company', { name: 'Сама', plan: 'team', billingModel: 'fixed' }); + const me = (await u.get('/api/me')).body; + const sa = await superadmin(); + await t.db.update(users).set({ telegramId: ++tgId }).where(eq(users.id, me.user.id)); + + const bill = (await u.get('/api/billing')).body; + expect(bill.access).toBe('disabled'); + expect(bill.invoices.map((i: any) => [i.kind, i.amount, i.status])).toEqual([['fixed', 350, 'pending'], ['setup', 500, 'pending']]); + + const fixed = bill.invoices.find((i: any) => i.kind === 'fixed'); + const paid = await sa.post(`/api/admin/invoices/${fixed.id}/pay`); + expect(paid.body).toMatchObject({ status: 'paid', paidMethod: 'manual', periodStart: today(), periodEnd: addMonths(today(), 1) }); + // Повторная отметка ничего не меняет. + await sa.post(`/api/admin/invoices/${fixed.id}/pay`); + expect(await t.db.select().from(payments).where(eq(payments.invoiceId, fixed.id))).toHaveLength(1); + + const after = (await u.get('/api/me')).body.company; + expect(after.access).toBe('active'); + expect(ymd(new Date(after.subscriptionEndsAt))).toBe(addMonths(today(), 1)); + await flush(); + expect(tg.inbox(tgId).at(-1)!.text).toContain('Оплата получена'); + }); + + it('счёт на следующий месяц и напоминания за 7, 3 и 1 день; оплата продлевает на месяц', async () => { + const c = await company('team', 'fixed', 30); + const end = addDays(today(), 30); + // Компания подключена вручную: начислена только установка. + expect((await invoicesOf(c.id)).map((i) => i.kind)).toEqual(['setup']); + + await runBillingJobs(t.db, noon(addDays(end, -8))); + expect(await invoicesOf(c.id)).toHaveLength(1); + + await runBillingJobs(t.db, noon(addDays(end, -7))); + await runBillingJobs(t.db, noon(addDays(end, -7))); + const list = await invoicesOf(c.id); + expect(list).toHaveLength(2); + expect(list[1]).toMatchObject({ kind: 'fixed', amount: 350, status: 'pending', periodStart: addDays(end, 1), periodEnd: addMonths(end, 1) }); + await flush(); + const got = () => tg.inbox(c.boss.chat).map((m) => m.text); + expect(got().filter((x) => x.includes('осталось 7 дн'))).toHaveLength(1); + expect(got().filter((x) => x.includes('Выставлен счёт на $350'))).toHaveLength(1); + + // Ночью напоминание не уходит. + await runBillingJobs(t.db, new Date(`${addDays(end, -3)}T03:00:00+04:00`)); + await flush(); + expect(got().some((x) => x.includes('осталось 3 дн'))).toBe(false); + await runBillingJobs(t.db, noon(addDays(end, -3))); + await runBillingJobs(t.db, noon(addDays(end, -1))); + await flush(); + expect(got().filter((x) => x.includes('осталось 3 дн') || x.includes('осталось 1 дн'))).toHaveLength(2); + + await c.sa.post(`/api/admin/invoices/${list[1]!.id}/pay`); + expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addMonths(end, 1)); + // Следующий счёт появится только за 7 дней до нового окончания. + await runBillingJobs(t.db, noon(end)); + expect(await invoicesOf(c.id)).toHaveLength(2); + }); + + it('16-й день просрочки: доступ ограничен, после отметки оплаты возвращается', async () => { + const c = await company('team', 'fixed', -16); + const end = addDays(today(), -16); + expect((await c.boss.api.get('/api/me')).body.company.access).toBe('restricted'); + expect((await c.boss.api.post('/api/invites')).body.code).toBe('SUBSCRIPTION_REQUIRED'); + + await runBillingJobs(t.db, noon(today())); + await runBillingJobs(t.db, noon(today())); + await flush(); + expect(tg.inbox(c.boss.chat).filter((m) => m.text.includes('Доступ ограничен'))).toHaveLength(1); + + // Раздел «Подписка» открыт и при ограниченном доступе. + const bill = (await c.boss.api.get('/api/billing')).body; + const fixed = bill.invoices.find((i: any) => i.kind === 'fixed' && i.status === 'pending'); + expect(fixed).toMatchObject({ amount: 350, periodStart: addDays(end, 1) }); + await c.sa.post(`/api/admin/invoices/${fixed.id}/pay`); + expect((await c.boss.api.get('/api/me')).body.company.access).toBe('active'); + expect((await c.boss.api.post('/api/invites')).status).toBe(200); + }); + + it('просрочка: ежедневное напоминание с числом оставшихся дней', async () => { + const c = await company('solo', 'fixed', -2); + await runBillingJobs(t.db, noon(today())); + await runBillingJobs(t.db, noon(today())); + await runBillingJobs(t.db, noon(addDays(today(), 1))); + await flush(); + const texts = tg.inbox(c.boss.chat).map((m) => m.text).filter((x) => x.includes('просрочена')); + expect(texts).toHaveLength(2); + expect(texts[0]).toContain('ещё 13 дн'); + expect(texts[1]).toContain('ещё 12 дн'); + }); +}); + +describe('модель «%»', () => { + const lastMonthEnd = () => addDays(`${today().slice(0, 7)}-01`, -1); + + it('счёт 1-го числа совпадает с ручным расчётом; оплата продлевает до 8-го числа следующего месяца', async () => { + const c = await company('agency', 'percent', 5); + const to = lastMonthEnd(); + const from = `${to.slice(0, 7)}-01`; + await t.db.update(companies).set({ percentFrom: addDays(from, 2) }).where(eq(companies.id, c.id)); + const deal = (commission: number, currency: string, dealDate: string) => + t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission, ownerCommission: commission, currency, dealDate }); + await deal(3000, 'USD', addDays(from, 5)); + await deal(385_000, 'AMD', to); + // Не попадают: сделка до перехода на «%» и сделка текущего месяца. + await deal(9000, 'USD', from); + await deal(7000, 'USD', today()); + + const first = noon(`${today().slice(0, 7)}-01`); + await runBillingJobs(t.db, first); + await runBillingJobs(t.db, first); + const list = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent'); + expect(list).toHaveLength(1); + // Вручную: (3000 + 385000 / 385) × 7 % = 4000 × 0,07 = 280. + expect(list[0]).toMatchObject({ amount: 280, status: 'pending', periodStart: from, periodEnd: to }); + expect(list[0]!.details).toMatchObject({ percent: 7, total: 4000, deals: [{ commission: 3000, usd: 3000 }, { commission: 385_000, currency: 'AMD', usd: 1000 }] }); + + // Клиент видит расшифровку и то, что набежало за текущий месяц. + const bill = (await c.boss.api.get('/api/billing')).body; + expect(bill.invoices.find((i: any) => i.kind === 'percent').details.deals).toHaveLength(2); + expect(bill.preview).toMatchObject({ total: 7000, amount: 490 }); + + await c.sa.post(`/api/admin/invoices/${list[0]!.id}/pay`); + expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(`${addMonths(`${today().slice(0, 7)}-01`, 1).slice(0, 7)}-08`); + }); + + it('месяц без сделок: нулевой счёт закрывается сам и продлевает подписку', async () => { + const c = await company('team', 'percent', 5); + await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id)); + await runBillingJobs(t.db, noon(`${today().slice(0, 7)}-01`)); + const [inv] = (await invoicesOf(c.id)).filter((i) => i.kind === 'percent'); + expect(inv).toMatchObject({ amount: 0, status: 'paid', paidMethod: 'auto' }); + expect(ymd((await row(c.id)).subscriptionEndsAt!).slice(8)).toBe('08'); + }); + + it('суперадмин меняет курсы валют для счёта', async () => { + const sa = await superadmin(); + expect((await sa.get('/api/admin/billing/settings')).body.rates.AMD).toBe(385); + expect((await sa.patch('/api/admin/billing/settings', { rates: { AMD: 400, RUB: 90, EUR: 0.9 } })).status).toBe(200); + expect((await sa.get('/api/admin/billing/settings')).body.rates).toEqual({ AMD: 400, RUB: 90, EUR: 0.9 }); + await sa.patch('/api/admin/billing/settings', { rates: { AMD: 385, RUB: 95, EUR: 0.92 } }); + }); +}); + +describe('смена тарифа', () => { + it('из кабинета применяется только со следующего месяца', async () => { + const c = await company('team', 'fixed', 20); + const end = addDays(today(), 20); + const agentInvite = (await c.boss.api.post('/api/invites')).body.token as string; + const agent = await join('Агент', agentInvite); + + expect((await agent.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).status).toBe(403); + expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'percent' })).body.code).toBe('BILLING_MODEL_NOT_ALLOWED'); + // В компании двое — Solo не вмещает. + expect((await c.boss.api.post('/api/billing/plan', { plan: 'solo', billingModel: 'fixed' })).body.code).toBe('PLAN_TOO_SMALL'); + + const res = await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' }); + expect(res.body.from).toBe(addDays(end, 1)); + const me = (await c.boss.api.get('/api/me')).body.company; + expect(me).toMatchObject({ plan: 'team', maxMembers: 10, planned: { plan: 'agency', billingModel: 'fixed', from: addDays(end, 1) } }); + + // Счёт на следующий месяц уже по новому тарифу; сам тариф пока прежний. + await runBillingJobs(t.db, noon(addDays(end, -7))); + const next = (await invoicesOf(c.id)).find((i) => i.kind === 'fixed')!; + expect(next).toMatchObject({ amount: 550, periodStart: addDays(end, 1) }); + expect((await row(c.id)).plan).toBe('team'); + + // Передумали: счёт пересоздаётся по старому тарифу. + await c.boss.api.del('/api/billing/plan'); + expect((await c.boss.api.get('/api/me')).body.company.planned).toBeNull(); + await c.boss.api.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' }); + await runBillingJobs(t.db, noon(addDays(end, -7))); + const pending = (await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending'); + expect(pending.map((i) => i.amount)).toEqual([550]); + + await runBillingJobs(t.db, noon(addDays(end, 1))); + const after = await row(c.id); + expect(after).toMatchObject({ plan: 'agency', billingModel: 'fixed', plannedPlan: null, plannedFrom: null }); + // Установка начислена один раз — при подключении Team. + expect((await invoicesOf(c.id)).filter((i) => i.kind === 'setup')).toHaveLength(1); + }); + + it('переход с фикса на «%»: сделки до перехода в счёт не попадают', async () => { + const c = await company('team', 'fixed', 3); + const from = addDays(today(), 4); + expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'percent' })).body.from).toBe(from); + await runBillingJobs(t.db, noon(from)); + const after = await row(c.id); + expect(after).toMatchObject({ billingModel: 'percent', percentFrom: from }); + expect(ymd(after.subscriptionEndsAt!)).toBe(`${addMonths(from, 1).slice(0, 7)}-08`); + // Неоплаченный счёт на фикс за следующий месяц отменён. + expect((await invoicesOf(c.id)).filter((i) => i.kind === 'fixed' && i.status === 'pending')).toHaveLength(0); + }); + + it('подписка ещё не начиналась: тариф меняется сразу, счета пересчитываются', async () => { + const u = await t.register('Новый'); + await u.post('/api/company', { name: 'Новая', plan: 'solo', billingModel: 'fixed' }); + const id = (await u.get('/api/me')).body.company.id as number; + expect((await invoicesOf(id)).map((i) => [i.kind, i.amount])).toEqual([['fixed', 40]]); + expect((await u.post('/api/billing/plan', { plan: 'agency', billingModel: 'fixed' })).body.from).toBeNull(); + expect((await u.get('/api/me')).body.company.plan).toBe('agency'); + const open = (await invoicesOf(id)).filter((i) => i.status === 'pending').map((i) => [i.kind, i.amount]); + expect(open).toEqual([['setup', 750], ['fixed', 550]]); + }); + + it('уход с «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом', async () => { + const c = await company('team', 'percent', -1); + const first = `${today().slice(0, 7)}-01`; + if (today() === first) return; + await t.db.update(companies).set({ percentFrom: '2020-01-01' }).where(eq(companies.id, c.id)); + await t.db.insert(deals).values({ companyId: c.id, agentId: c.boss.id, ownerAgentId: c.boss.id, clientAgentId: c.boss.id, commission: 2000, ownerCommission: 2000, currency: 'USD', dealDate: first }); + // Подписка закончилась — тариф меняется сразу. + expect((await c.boss.api.post('/api/billing/plan', { plan: 'team', billingModel: 'fixed' })).body.from).toBeNull(); + const list = await invoicesOf(c.id); + expect(list.find((i) => i.kind === 'percent')).toMatchObject({ amount: 200, periodStart: first, periodEnd: addDays(today(), -1), status: 'pending' }); + expect(list.find((i) => i.kind === 'fixed')).toMatchObject({ amount: 350, status: 'pending' }); + }); + + it('суперадмин меняет тариф сразу', async () => { + const c = await company('team', 'fixed', 20); + expect((await c.sa.patch(`/api/admin/companies/${c.id}`, { plan: 'agency', billingModel: 'percent' })).status).toBe(200); + expect(await row(c.id)).toMatchObject({ plan: 'agency', billingModel: 'percent', percentFrom: today() }); + }); +}); + +describe('онлайн-оплата через провайдера', () => { + it('ссылка на оплату, webhook закрывает счёт; неверная подпись отклоняется', async () => { + const u = await t.register('Онлайн'); + await u.post('/api/company', { name: 'Онлайн', plan: 'solo', billingModel: 'fixed' }); + const id = (await u.get('/api/me')).body.company.id as number; + const [inv] = await invoicesOf(id); + expect((await t.client().get('/api/config')).body.onlinePayments).toBe(true); + expect((await u.post(`/api/invoices/${inv!.id}/pay`)).body.url).toBe(`https://pay.test/${inv!.id}`); + + const hook = (secret: string, status = 'succeeded') => + t.app.inject({ method: 'POST', url: '/api/payments/webhook/fakepay', headers: { 'x-secret': secret }, payload: { externalId: `pay-${inv!.id}`, status } }); + expect((await hook('wrong')).statusCode).toBe(400); + expect((await t.app.inject({ method: 'POST', url: '/api/payments/webhook/other', payload: {} })).statusCode).toBe(404); + expect((await hook('ok')).statusCode).toBe(200); + expect((await hook('ok')).statusCode).toBe(200); + + expect((await invoicesOf(id))[0]).toMatchObject({ status: 'paid', paidMethod: 'online' }); + expect(await t.db.select().from(payments).where(eq(payments.invoiceId, inv!.id))).toMatchObject([{ provider: 'fakepay', status: 'succeeded' }]); + expect((await u.get('/api/me')).body.company.access).toBe('active'); + // Чужой счёт оплатить нельзя. + const other = await company('solo'); + expect((await other.boss.api.post(`/api/invoices/${inv!.id}/pay`)).status).toBe(404); + }); +}); + +describe('суперадмин', () => { + it('счета: список, отмена; сводка считает выручку месяца', async () => { + const c = await company('agency', 'fixed', 30); + const before = (await c.sa.get('/api/admin/summary')).body; + const [setup] = await invoicesOf(c.id); + const list = (await c.sa.get(`/api/admin/invoices?status=pending&companyId=${c.id}`)).body; + expect(list).toMatchObject([{ id: setup!.id, kind: 'setup', amount: 750, company: 'Компания agency' }]); + expect((await c.boss.api.get('/api/admin/invoices')).status).toBe(403); + expect((await c.boss.api.post(`/api/admin/invoices/${setup!.id}/pay`)).status).toBe(403); + + await c.sa.post(`/api/admin/invoices/${setup!.id}/pay`); + const after = (await c.sa.get('/api/admin/summary')).body; + expect(after.revenueMonth - before.revenueMonth).toBe(750); + expect(after.pendingAmount - before.pendingAmount).toBe(-750); + expect(after.active).toBeGreaterThan(0); + // Оплата установки дату подписки не двигает. + expect(ymd((await row(c.id)).subscriptionEndsAt!)).toBe(addDays(today(), 30)); + expect((await c.sa.post(`/api/admin/invoices/${setup!.id}/cancel`)).body.code).toBe('INVOICE_PAID'); + + const d = await company('team'); + const [s2] = await invoicesOf(d.id); + expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/cancel`)).status).toBe(200); + expect((await d.sa.post(`/api/admin/invoices/${s2!.id}/pay`)).body.code).toBe('INVOICE_CANCELED'); + }); + + it('сообщения в Telegram: всем, компании, руководителям, одному пользователю; журнал', async () => { + const a = await company('team'); + const b = await company('team'); + const agent = await join('Агент', (await a.boss.api.post('/api/invites')).body.token); + // Пользователь без Telegram сообщение не получит — это видно в журнале. + const silent = await join('Без телеграма', (await a.boss.api.post('/api/invites')).body.token); + await t.db.update(users).set({ telegramId: null }).where(eq(users.id, silent.id)); + await flush(); + const count = (text: string, chat: number) => tg.inbox(chat).filter((m) => m.text === text).length; + + expect((await a.boss.api.post('/api/admin/messages', { text: 'x', audience: 'all' })).status).toBe(403); + expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'company' })).body.code).toBe('VALIDATION'); + expect((await a.sa.post('/api/admin/messages', { text: 'x', audience: 'user', userId: 999_999 })).body.code).toBe('NO_RECIPIENTS'); + + const toCompany = await a.sa.post('/api/admin/messages', { text: 'Компании A', audience: 'company', companyId: a.id }); + expect(toCompany.body).toMatchObject({ recipients: 2, unreachable: 1 }); + await a.sa.post('/api/admin/messages', { text: 'Руководителям', audience: 'managers' }); + await a.sa.post('/api/admin/messages', { text: 'Лично', audience: 'user', userId: agent.id }); + await a.sa.post('/api/admin/messages', { text: 'Всем', audience: 'all' }); + await flush(); + + // Разметка в тексте суперадмина экранируется. + expect(count('Компании <b>A</b>', a.boss.chat)).toBe(1); + expect(count('Компании <b>A</b>', agent.chat)).toBe(1); + expect(count('Компании <b>A</b>', b.boss.chat)).toBe(0); + expect([count('Руководителям', a.boss.chat), count('Руководителям', b.boss.chat), count('Руководителям', agent.chat)]).toEqual([1, 1, 0]); + expect([count('Лично', agent.chat), count('Лично', a.boss.chat)]).toEqual([1, 0]); + expect([count('Всем', a.boss.chat), count('Всем', b.boss.chat), count('Всем', agent.chat)]).toEqual([1, 1, 1]); + + const journal = (await a.sa.get('/api/admin/messages')).body; + expect(journal.slice(0, 4).map((m: any) => m.audience)).toEqual(['all', 'user', 'managers', 'company']); + expect(journal[1]).toMatchObject({ text: 'Лично', user: 'Агент', recipients: 1 }); + expect(journal[3]).toMatchObject({ company: 'Компания team', recipients: 2, unreachable: 1 }); + + const found = (await a.sa.get('/api/admin/users?q=Без')).body; + expect(found).toMatchObject([{ id: silent.id, telegramLinked: false, company: 'Компания team' }]); + }); +}); + +describe('мастер настройки', () => { + it('показывается один раз: после отметки больше не нужен', async () => { + const c = await company('team'); + expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(false); + expect((await c.boss.api.patch('/api/me', { onboarded: true })).status).toBe(200); + expect((await c.boss.api.get('/api/me')).body.user.onboarded).toBe(true); + // Правка профиля отметку не сбрасывает. + await c.boss.api.patch('/api/me', { name: 'Новое имя' }); + const [u] = await t.db.select().from(users).where(and(eq(users.id, c.boss.id))); + expect(u!.onboardedAt).not.toBeNull(); + }); +}); diff --git a/apps/api/src/billing/provider.ts b/apps/api/src/billing/provider.ts new file mode 100644 index 0000000..e2462f7 --- /dev/null +++ b/apps/api/src/billing/provider.ts @@ -0,0 +1,27 @@ +// Платёжный модуль (ТЗ, раздел 7): провайдер эквайринга ещё не выбран, поэтому биллинг знает +// только этот контракт. Чтобы подключить провайдера, нужно реализовать интерфейс и передать +// его в buildApp({ payments }) — счета, продление подписки и кабинет менять не придётся. + +export interface PaymentRequest { + invoiceId: number; + amount: number; + currency: string; + description: string; + /** Куда вернуть пользователя после оплаты. */ + returnUrl: string; +} + +export interface PaymentEvent { + /** Идентификатор платежа у провайдера — тот, что вернул createPayment. */ + externalId: string; + status: 'succeeded' | 'failed'; +} + +export interface PaymentProvider { + /** Короткое имя: попадает в адрес webhook (/api/payments/webhook/) и в историю платежей. */ + name: string; + /** Создаёт платёж у провайдера и возвращает ссылку на страницу оплаты. */ + createPayment(request: PaymentRequest): Promise<{ externalId: string; url: string }>; + /** Проверяет подпись уведомления и разбирает его. null — уведомление не о платеже или подпись неверна. */ + parseWebhook(headers: Record, body: unknown): Promise; +} diff --git a/apps/api/src/billing/routes.ts b/apps/api/src/billing/routes.ts new file mode 100644 index 0000000..6170838 --- /dev/null +++ b/apps/api/src/billing/routes.ts @@ -0,0 +1,297 @@ +import { accessState, BILLING_MODELS, PLANS, type AccessState } from '@lr/shared'; +import { and, desc, eq, ilike, isNotNull, isNull, or, sql, type SQL } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { adminMessages, companies, invoices, payments, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import { enqueueRaw } from '../telegram/outbox.ts'; +import type { PaymentProvider } from './provider.ts'; +import { + addDays, + BillingSettings, + cancelInvoice, + loadBillingSettings, + nextPeriodStart, + percentCalc, + saveBillingSettings, + schedulePlan, + settleInvoice, + ymd, + type Invoice, +} from './service.ts'; + +type Company = typeof companies.$inferSelect; +type AccountCtx = { user: typeof users.$inferSelect; company: Company; access: AccessState }; + +export interface BillingGuards { + /** Руководитель компании. Работает и при ограниченном доступе, и вне Mini App: иначе подписку не оплатить. */ + owner: (req: FastifyRequest) => AccountCtx; + superadmin: (req: FastifyRequest) => unknown; +} + +const Id = z.object({ id: z.coerce.number().int().positive() }); + +const invoiceView = (i: Invoice) => ({ + id: i.id, + kind: i.kind, + periodStart: i.periodStart, + periodEnd: i.periodEnd, + amount: i.amount, + currency: i.currency, + status: i.status, + paidAt: i.paidAt, + paidMethod: i.paidMethod, + details: i.details, + createdAt: i.createdAt, +}); + +export function registerBillingRoutes( + app: FastifyInstance, + db: Db, + g: BillingGuards, + config: Config, + provider: PaymentProvider | null, +) { + // --- кабинет: подписка, счета, смена тарифа --- + + app.get('/api/billing', async (req) => { + const { company } = g.owner(req); + const rows = await db.select().from(invoices).where(eq(invoices.companyId, company.id)).orderBy(desc(invoices.id)); + const today = ymd(new Date()); + // На модели «%» видно, сколько набежало за текущий месяц. + const preview = + company.billingModel === 'percent' + ? await percentCalc(db, company, `${today.slice(0, 7)}-01`, today) + : null; + return { + plan: company.plan, + billingModel: company.billingModel, + access: accessState(company), + subscriptionEndsAt: company.subscriptionEndsAt, + graceDays: company.graceDays, + planned: + company.plannedPlan && company.plannedFrom + ? { plan: company.plannedPlan, billingModel: company.plannedBillingModel, from: company.plannedFrom } + : null, + /** С какой даты вступит в силу смена тарифа; null — сразу. */ + changeFrom: nextPeriodStart(company), + onlinePayments: Boolean(provider), + invoices: rows.map(invoiceView), + preview: preview && { from: `${today.slice(0, 7)}-01`, to: today, amount: preview.amount, ...preview.details }, + }; + }); + + app.post('/api/billing/plan', async (req) => { + const { company } = g.owner(req); + const body = z.object({ plan: z.enum(PLANS), billingModel: z.enum(BILLING_MODELS) }).parse(req.body); + return schedulePlan(db, company.id, body.plan, body.billingModel); + }); + + /** Отменить запланированную смену тарифа. */ + app.delete('/api/billing/plan', async (req) => { + const { company } = g.owner(req); + return schedulePlan(db, company.id, company.plan as (typeof PLANS)[number], company.billingModel as (typeof BILLING_MODELS)[number]); + }); + + /** Онлайн-оплата счёта: возвращает ссылку на страницу провайдера. */ + app.post('/api/invoices/:id/pay', async (req) => { + const { company } = g.owner(req); + const { id } = Id.parse(req.params); + if (!provider) throw badRequest('PAYMENTS_NOT_CONFIGURED'); + const [invoice] = await db.select().from(invoices).where(and(eq(invoices.id, id), eq(invoices.companyId, company.id))); + if (!invoice) throw notFound(); + if (invoice.status !== 'pending') throw badRequest('INVOICE_NOT_PENDING'); + const created = await provider.createPayment({ + invoiceId: invoice.id, + amount: invoice.amount, + currency: invoice.currency, + description: `Y&P Lead Radar #${invoice.id}`, + returnUrl: `${config.appUrl}/billing`, + }); + await db.insert(payments).values({ + invoiceId: invoice.id, + provider: provider.name, + externalId: created.externalId, + amount: invoice.amount, + currency: invoice.currency, + status: 'pending', + }); + return { url: created.url }; + }); + + /** Уведомление провайдера о платеже. Подпись проверяет сам провайдер. */ + app.post('/api/payments/webhook/:provider', async (req) => { + const { provider: name } = z.object({ provider: z.string() }).parse(req.params); + if (!provider || provider.name !== name) throw notFound(); + const event = await provider.parseWebhook(req.headers, req.body); + if (!event) throw badRequest('BAD_WEBHOOK'); + await db.transaction(async (tx) => { + const [payment] = await tx + .select() + .from(payments) + .where(and(eq(payments.provider, name), eq(payments.externalId, event.externalId))) + .for('update'); + if (!payment || payment.status !== 'pending') return; + if (event.status === 'failed') { + await tx.update(payments).set({ status: 'failed' }).where(eq(payments.id, payment.id)); + return; + } + // Строка платежа уже есть — отмечаем её, а не создаём вторую. + await tx.update(payments).set({ status: 'succeeded' }).where(eq(payments.id, payment.id)); + await settleInvoice(tx, payment.invoiceId, 'online'); + }); + return { ok: true }; + }); + + // --- суперадмин: счета, сводка, курсы --- + + app.get('/api/admin/invoices', async (req) => { + g.superadmin(req); + const q = z + .object({ status: z.enum(['pending', 'paid', 'canceled']).optional(), companyId: z.coerce.number().int().optional() }) + .parse(req.query); + const where: SQL[] = []; + if (q.status) where.push(eq(invoices.status, q.status)); + if (q.companyId) where.push(eq(invoices.companyId, q.companyId)); + const rows = await db + .select({ invoice: invoices, company: companies.name }) + .from(invoices) + .innerJoin(companies, eq(companies.id, invoices.companyId)) + .where(and(...where)) + .orderBy(desc(invoices.id)) + .limit(200); + return rows.map((r) => ({ ...invoiceView(r.invoice), companyId: r.invoice.companyId, company: r.company })); + }); + + /** Ручной режим: суперадмин отмечает счёт оплаченным, подписка продлевается. */ + app.post('/api/admin/invoices/:id/pay', async (req) => { + g.superadmin(req); + const { id } = Id.parse(req.params); + return invoiceView(await db.transaction((tx) => settleInvoice(tx, id, 'manual'))); + }); + + app.post('/api/admin/invoices/:id/cancel', async (req) => { + g.superadmin(req); + const { id } = Id.parse(req.params); + await db.transaction((tx) => cancelInvoice(tx, id)); + return { ok: true }; + }); + + app.get('/api/admin/summary', async (req) => { + g.superadmin(req); + const all = await db.select().from(companies); + const states = all.map((c) => accessState(c)); + const month = `${ymd(new Date()).slice(0, 7)}-01`; + const next = addDays(month, 32).slice(0, 7) + '-01'; + const [money] = await db + .select({ + revenue: sql`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'paid' and ${invoices.paidAt} >= ${`${month}T00:00:00+04:00`}::timestamptz and ${invoices.paidAt} < ${`${next}T00:00:00+04:00`}::timestamptz), 0)::float`, + pending: sql`coalesce(sum(${invoices.amount}) filter (where ${invoices.status} = 'pending'), 0)::float`, + pendingCount: sql`(count(*) filter (where ${invoices.status} = 'pending'))::int`, + }) + .from(invoices); + return { + companies: all.length, + active: states.filter((s) => s === 'active').length, + overdue: states.filter((s) => s === 'overdue').length, + restricted: states.filter((s) => s === 'restricted').length, + disabled: states.filter((s) => s === 'disabled').length, + revenueMonth: money?.revenue ?? 0, + pendingAmount: money?.pending ?? 0, + pendingCount: money?.pendingCount ?? 0, + }; + }); + + app.get('/api/admin/billing/settings', async (req) => { + g.superadmin(req); + return loadBillingSettings(db); + }); + + app.patch('/api/admin/billing/settings', async (req) => { + g.superadmin(req); + const value = BillingSettings.parse(req.body); + await saveBillingSettings(db, value); + return value; + }); + + // --- суперадмин: сообщения пользователям в Telegram --- + + /** Поиск пользователя для сообщения «одному пользователю». */ + app.get('/api/admin/users', async (req) => { + g.superadmin(req); + const { q } = z.object({ q: z.string().trim().max(100).default('') }).parse(req.query); + const like = `%${q}%`; + return db + .select({ + id: users.id, + name: users.name, + role: users.role, + company: companies.name, + telegramLinked: sql`${users.telegramId} is not null`, + }) + .from(users) + .innerJoin(companies, eq(companies.id, users.companyId)) + .where( + and( + isNull(users.removedAt), + q ? or(ilike(users.name, like), ilike(users.email, like), ilike(users.telegramUsername, like), ilike(companies.name, like)) : undefined, + ), + ) + .orderBy(users.name) + .limit(20); + }); + + app.post('/api/admin/messages', async (req) => { + g.superadmin(req); + const body = z + .object({ + text: z.string().trim().min(1).max(3500), + audience: z.enum(['all', 'company', 'managers', 'user']), + companyId: z.number().int().optional(), + userId: z.number().int().optional(), + }) + .parse(req.body); + if (body.audience === 'company' && !body.companyId) throw badRequest('VALIDATION'); + if (body.audience === 'user' && !body.userId) throw badRequest('VALIDATION'); + const where: SQL[] = [isNull(users.removedAt), isNotNull(users.companyId)]; + if (body.audience === 'company') where.push(eq(users.companyId, body.companyId!)); + if (body.audience === 'managers') { + where.push(eq(users.role, 'manager')); + if (body.companyId) where.push(eq(users.companyId, body.companyId)); + } + if (body.audience === 'user') where.push(eq(users.id, body.userId!)); + return db.transaction(async (tx) => { + const targets = await tx.select({ id: users.id, telegramId: users.telegramId }).from(users).where(and(...where)); + if (!targets.length) throw badRequest('NO_RECIPIENTS'); + const reachable = targets.filter((u) => u.telegramId); + await enqueueRaw(tx, reachable.map((u) => u.id), body.text); + const [message] = await tx + .insert(adminMessages) + .values({ + text: body.text, + audience: body.audience, + companyId: body.audience === 'user' ? null : (body.companyId ?? null), + userId: body.audience === 'user' ? body.userId! : null, + recipients: reachable.length, + unreachable: targets.length - reachable.length, + }) + .returning(); + return { id: message!.id, recipients: reachable.length, unreachable: targets.length - reachable.length }; + }); + }); + + /** Журнал отправленных сообщений. */ + app.get('/api/admin/messages', async (req) => { + g.superadmin(req); + const rows = await db + .select({ message: adminMessages, company: companies.name, user: users.name }) + .from(adminMessages) + .leftJoin(companies, eq(companies.id, adminMessages.companyId)) + .leftJoin(users, eq(users.id, adminMessages.userId)) + .orderBy(desc(adminMessages.id)) + .limit(100); + return rows.map((r) => ({ ...r.message, company: r.company, user: r.user })); + }); +} diff --git a/apps/api/src/billing/service.ts b/apps/api/src/billing/service.ts new file mode 100644 index 0000000..36a66ef --- /dev/null +++ b/apps/api/src/billing/service.ts @@ -0,0 +1,479 @@ +import { + accessState, + allowedBillingModels, + PLAN_SPECS, + type BillingModel, + type Plan, +} from '@lr/shared'; +import { and, asc, eq, gte, isNull, lte, ne, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import type { Db, Tx } from '../db/client.ts'; +import { clients, companies, deals, invoices, leads, payments, settings, users } from '../db/schema.ts'; +import { badRequest, conflict, notFound } from '../lib/errors.ts'; +import { enqueueText } from '../telegram/outbox.ts'; + +// Биллинг (ТЗ, раздел 7). +// +// Фикс — предоплата: счёт на следующий месяц выставляется за 7 дней до окончания подписки, +// оплата продлевает подписку до конца оплаченного периода. +// «%» — постоплата по календарным месяцам: 1-го числа выставляется счёт за сделки прошлого +// месяца, оплатить его нужно до 8-го. Оплата продлевает подписку до 8-го числа следующего месяца. +// Все даты считаются по времени Еревана. + +type Company = typeof companies.$inferSelect; +export type Invoice = typeof invoices.$inferSelect; + +const DAY = 86_400_000; +/** За сколько дней до окончания подписки выставляется счёт на фикс и на какой день месяца срок оплаты «%». */ +const INVOICE_LEAD_DAYS = 7; +const PERCENT_DUE_DAY = 8; +const REMIND_DAYS = [7, 3, 1]; +/** Напоминания не уходят ночью: раньше этого часа по Еревану задача их не отправляет. */ +const REMIND_FROM_HOUR = 10; + +/** Дата по Еревану в виде «ГГГГ-ММ-ДД». */ +export const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10); +/** Подписка действует до конца дня по Еревану. */ +export const endOfDay = (day: string) => new Date(`${day}T23:59:59+04:00`); +const utc = (day: string) => new Date(`${day}T00:00:00Z`); +export const addDays = (day: string, n: number) => new Date(utc(day).getTime() + n * DAY).toISOString().slice(0, 10); +export const daysBetween = (from: string, to: string) => Math.round((utc(to).getTime() - utc(from).getTime()) / DAY); +/** Тот же день следующего месяца; 31 января → 28 (29) февраля. */ +export function addMonths(day: string, n: number): string { + const d = utc(day); + const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1)); + const last = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate(); + target.setUTCDate(Math.min(d.getUTCDate(), last)); + return target.toISOString().slice(0, 10); +} +const monthStart = (day: string) => `${day.slice(0, 7)}-01`; +const percentDue = (month: string) => `${month.slice(0, 7)}-${String(PERCENT_DUE_DAY).padStart(2, '0')}`; +const round2 = (n: number) => Math.round(n * 100) / 100; + +// --- курсы валют для счёта по комиссии --- + +/** Сколько единиц валюты в одном долларе. Комиссия по сделке вводится в любой валюте, счёт — в USD. */ +export const BillingSettings = z.object({ + rates: z + .object({ + AMD: z.number().positive().default(385), + RUB: z.number().positive().default(95), + EUR: z.number().positive().default(0.92), + }) + .default({ AMD: 385, RUB: 95, EUR: 0.92 }), +}); +export type BillingSettings = z.infer; + +export async function loadBillingSettings(db: Tx): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, 'billing')); + return BillingSettings.parse(row?.value ?? {}); +} + +export async function saveBillingSettings(db: Tx, value: BillingSettings) { + await db + .insert(settings) + .values({ key: 'billing', value }) + .onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } }); +} + +const toUsd = (amount: number, currency: string, rates: BillingSettings['rates']) => + currency === 'USD' ? amount : amount / (rates[currency as keyof typeof rates] ?? 1); + +// --- расчёт по модели «%» --- + +export interface PercentDeal { + dealId: number; + date: string; + title: string; + agent: string; + commission: number; + currency: string; + /** Комиссия в долларах по курсу на момент счёта. */ + usd: number; +} + +export interface PercentDetails { + plan: Plan; + percent: number; + rates: BillingSettings['rates']; + /** Сумма комиссий в долларах. */ + total: number; + deals: PercentDeal[]; +} + +/** Сделки компании за период и сумма к оплате: комиссии × процент тарифа. */ +export async function percentCalc(tx: Tx, company: Company, from: string, to: string) { + const { rates } = await loadBillingSettings(tx); + const since = company.percentFrom && company.percentFrom > from ? company.percentFrom : from; + const rows = await tx + .select({ + deal: deals, + leadTitle: leads.title, + clientName: clients.name, + agent: users.name, + }) + .from(deals) + .leftJoin(leads, eq(leads.id, deals.leadId)) + .leftJoin(clients, eq(clients.id, deals.clientId)) + .innerJoin(users, eq(users.id, deals.agentId)) + .where(and(eq(deals.companyId, company.id), gte(deals.dealDate, since), lte(deals.dealDate, to))) + .orderBy(asc(deals.dealDate), asc(deals.id)); + const plan = company.plan as Plan; + const percent = PLAN_SPECS[plan].commissionPercent ?? 0; + const list: PercentDeal[] = rows.map((r) => ({ + dealId: r.deal.id, + date: r.deal.dealDate, + title: [r.leadTitle, r.clientName].filter(Boolean).join(' · '), + agent: r.agent, + commission: r.deal.commission, + currency: r.deal.currency, + usd: round2(toUsd(r.deal.commission, r.deal.currency, rates)), + })); + const total = round2(list.reduce((sum, d) => sum + d.usd, 0)); + const details: PercentDetails = { plan, percent, rates, total, deals: list }; + return { details, amount: round2((total * percent) / 100) }; +} + +// --- выставление счетов --- + +const managersOf = async (tx: Tx, companyId: number) => + ( + await tx + .select({ id: users.id }) + .from(users) + .where(and(eq(users.companyId, companyId), eq(users.role, 'manager'), isNull(users.removedAt))) + ).map((u) => u.id); + +const money = (n: number) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`; + +async function issue( + tx: Tx, + company: Company, + v: { kind: 'setup' | 'fixed' | 'percent'; amount: number; periodStart?: string | null; periodEnd?: string | null; details?: unknown }, + notify = true, +): Promise { + const [invoice] = await tx + .insert(invoices) + .values({ + companyId: company.id, + kind: v.kind, + amount: v.amount, + periodStart: v.periodStart ?? null, + periodEnd: v.periodEnd ?? null, + details: v.details ?? { plan: company.plan }, + }) + .returning(); + if (notify && v.amount > 0) await enqueueText(tx, await managersOf(tx, company.id), 'invoiceIssued', [money(v.amount)]); + return invoice!; +} + +/** Плата за установку начисляется один раз — при первом подключении Team или Agency. */ +export async function issueSetupIfNeeded(tx: Tx, company: Company, notify = true) { + const fee = PLAN_SPECS[company.plan as Plan].setupFeeUsd; + if (!fee || company.setupFeeInvoiced) return; + await tx.update(companies).set({ setupFeeInvoiced: true }).where(eq(companies.id, company.id)); + await issue(tx, company, { kind: 'setup', amount: fee }, notify); +} + +/** Счета новой компании, которая подключается сама: установка и первый месяц фикса. */ +export async function issueInitialInvoices(tx: Tx, company: Company) { + await issueSetupIfNeeded(tx, company, false); + if (company.billingModel === 'fixed') { + // Период первого счёта начинается в день оплаты. + await issue(tx, company, { kind: 'fixed', amount: PLAN_SPECS[company.plan as Plan].monthlyUsd }, false); + } +} + +/** Тариф и модель, которые будут действовать у компании на указанную дату. */ +function planOn(company: Company, day: string): { plan: Plan; model: BillingModel } { + if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= day) { + return { plan: company.plannedPlan as Plan, model: (company.plannedBillingModel ?? company.billingModel) as BillingModel }; + } + return { plan: company.plan as Plan, model: company.billingModel as BillingModel }; +} + +/** С какой даты вступит в силу смена тарифа; null — сразу (подписка ещё не начиналась или уже закончилась). */ +export function nextPeriodStart(company: Company, now = new Date()): string | null { + if (!company.subscriptionEndsAt || company.subscriptionEndsAt < now) return null; + return company.billingModel === 'percent' + ? addMonths(monthStart(ymd(now)), 1) + : addDays(ymd(company.subscriptionEndsAt), 1); +} + +/** Фикс: счёт на следующий месяц за 7 дней до окончания подписки. */ +async function ensureFixedInvoice(tx: Tx, company: Company, today: string) { + if (!company.subscriptionEndsAt) return; + const end = ymd(company.subscriptionEndsAt); + if (daysBetween(today, end) > INVOICE_LEAD_DAYS) return; + const periodStart = addDays(end, 1); + const next = planOn(company, periodStart); + if (next.model !== 'fixed') return; + const [exists] = await tx + .select({ id: invoices.id }) + .from(invoices) + .where( + and( + eq(invoices.companyId, company.id), + eq(invoices.kind, 'fixed'), + ne(invoices.status, 'canceled'), + sql`(${invoices.periodStart} = ${periodStart} or (${invoices.periodStart} is null and ${invoices.status} = 'pending'))`, + ), + ); + if (exists) return; + await issue(tx, company, { + kind: 'fixed', + amount: PLAN_SPECS[next.plan].monthlyUsd, + periodStart, + periodEnd: addMonths(end, 1), + details: { plan: next.plan }, + }); +} + +/** «%»: 1-го числа счёт за сделки прошлого месяца. Нулевой счёт закрывается сам и продлевает подписку. */ +async function ensurePercentInvoice(tx: Tx, company: Company, today: string) { + const to = addDays(monthStart(today), -1); + const from = monthStart(to); + if (!company.percentFrom || company.percentFrom > to) return; + const [exists] = await tx + .select({ id: invoices.id }) + .from(invoices) + .where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'percent'), eq(invoices.periodStart, from))); + if (exists) return; + const { details, amount } = await percentCalc(tx, company, from, to); + const invoice = await issue(tx, company, { kind: 'percent', amount, periodStart: from, periodEnd: to, details }); + if (amount === 0) await settleInvoice(tx, invoice.id, 'auto'); +} + +// --- оплата --- + +/** + * Отмечает счёт оплаченным и продлевает подписку. Вызывается суперадмином вручную + * и платёжным модулем после подтверждения платежа. + */ +export async function settleInvoice( + tx: Tx, + invoiceId: number, + method: 'manual' | 'online' | 'auto', + now = new Date(), +): Promise { + const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update'); + if (!invoice) throw notFound(); + if (invoice.status === 'paid') return invoice; + if (invoice.status !== 'pending') throw conflict('INVOICE_CANCELED'); + const [company] = await tx.select().from(companies).where(eq(companies.id, invoice.companyId)).for('update'); + const today = ymd(now); + let { periodStart, periodEnd } = invoice; + let endsAt: Date | null = null; + + if (invoice.kind === 'fixed') { + // Первый счёт или оплата после долгого простоя: месяц отсчитывается от дня оплаты. + if (!periodStart || !periodEnd || periodEnd < today) { + periodStart = today; + periodEnd = addMonths(today, 1); + } + endsAt = endOfDay(periodEnd); + } else if (invoice.kind === 'percent' && company!.billingModel === 'percent') { + endsAt = endOfDay(percentDue(addMonths(addDays(invoice.periodEnd!, 1), 1))); + } else if (invoice.kind === 'setup' && company!.billingModel === 'percent' && !company!.subscriptionEndsAt) { + // На модели «%» первого месячного счёта нет: подписка стартует с оплаты установки. + endsAt = endOfDay(percentDue(addMonths(today, 1))); + } + + const [paid] = await tx + .update(invoices) + .set({ status: 'paid', paidAt: now, paidMethod: method, periodStart, periodEnd }) + .where(eq(invoices.id, invoiceId)) + .returning(); + // У онлайн-платежа строка уже есть: её создал запрос на оплату, а подтвердил webhook. + if (method === 'manual') { + await tx.insert(payments).values({ + invoiceId, + provider: 'manual', + externalId: null, + amount: invoice.amount, + currency: invoice.currency, + status: 'succeeded', + }); + } + if (endsAt && (!company!.subscriptionEndsAt || endsAt > company!.subscriptionEndsAt)) { + await tx + .update(companies) + .set({ + subscriptionEndsAt: endsAt, + accessEnabled: true, + billingNotice: null, + ...(company!.billingModel === 'percent' && !company!.percentFrom ? { percentFrom: today } : {}), + }) + .where(eq(companies.id, company!.id)); + if (method !== 'auto') { + await enqueueText(tx, await managersOf(tx, company!.id), 'paymentReceived', [ymd(endsAt)]); + } + } + return paid!; +} + +export async function cancelInvoice(tx: Tx, invoiceId: number) { + const [invoice] = await tx.select().from(invoices).where(eq(invoices.id, invoiceId)).for('update'); + if (!invoice) throw notFound(); + if (invoice.status === 'paid') throw conflict('INVOICE_PAID'); + await tx.update(invoices).set({ status: 'canceled' }).where(eq(invoices.id, invoiceId)); + if (invoice.kind === 'setup') { + await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, invoice.companyId)); + } +} + +// --- смена тарифа --- + +const cancelPendingFixed = (tx: Tx, companyId: number) => + tx + .update(invoices) + .set({ status: 'canceled' }) + .where(and(eq(invoices.companyId, companyId), eq(invoices.kind, 'fixed'), eq(invoices.status, 'pending'))); + +/** Применяет тариф и модель прямо сейчас (суперадмин, запланированная дата, неначатая подписка). */ +export async function applyPlan(tx: Tx, company: Company, plan: Plan, model: BillingModel, now = new Date()): Promise { + const today = ymd(now); + const toPercent = model === 'percent' && company.billingModel !== 'percent'; + const running = Boolean(company.subscriptionEndsAt); + // Уход с модели «%» посреди месяца: сделки с начала месяца закрываются отдельным счётом, + // иначе они не попали бы ни в один. + if (running && company.billingModel === 'percent' && model !== 'percent' && today !== monthStart(today)) { + const to = addDays(today, -1); + const { details, amount } = await percentCalc(tx, company, monthStart(today), to); + if (amount > 0) await issue(tx, company, { kind: 'percent', amount, periodStart: monthStart(today), periodEnd: to, details }); + } + const [updated] = await tx + .update(companies) + .set({ + plan, + billingModel: model, + plannedPlan: null, + plannedBillingModel: null, + plannedFrom: null, + ...(model === 'percent' && (toPercent || !company.percentFrom) ? { percentFrom: today } : {}), + // Первый счёт по комиссии придёт 1-го числа следующего месяца, срок оплаты — 8-е. + ...(toPercent && running ? { subscriptionEndsAt: endOfDay(percentDue(addMonths(today, 1))), billingNotice: null } : {}), + }) + .where(eq(companies.id, company.id)) + .returning(); + if (plan !== company.plan || model !== company.billingModel) await cancelPendingFixed(tx, company.id); + let current = updated!; + if (!running && plan !== company.plan) { + // Подписка ещё не начиналась: неоплаченный счёт за установку пересчитывается под новый тариф. + const dropped = await tx + .update(invoices) + .set({ status: 'canceled' }) + .where(and(eq(invoices.companyId, company.id), eq(invoices.kind, 'setup'), eq(invoices.status, 'pending'))) + .returning({ id: invoices.id }); + if (dropped.length) { + [current] = await tx.update(companies).set({ setupFeeInvoiced: false }).where(eq(companies.id, company.id)).returning() as [Company]; + } + } + await issueSetupIfNeeded(tx, current, running); + if (!running && model === 'fixed') { + await issue(tx, updated!, { kind: 'fixed', amount: PLAN_SPECS[plan].monthlyUsd }, false); + } + // Подписка идёт или закончилась: счёт на следующий месяц выставляется по новому тарифу. + if (running && model === 'fixed') await ensureFixedInvoice(tx, updated!, today); + return updated!; +} + +/** + * Смена тарифа из кабинета: вступает в силу со следующего расчётного месяца. + * Если подписка ещё не начиналась или уже закончилась, меняется сразу. + */ +export async function schedulePlan(db: Db, companyId: number, plan: Plan, model: BillingModel, now = new Date()) { + if (!allowedBillingModels(plan).includes(model)) throw badRequest('BILLING_MODEL_NOT_ALLOWED'); + return db.transaction(async (tx) => { + const [company] = await tx.select().from(companies).where(eq(companies.id, companyId)).for('update'); + if (!company) throw notFound(); + const [{ n: members } = { n: 0 }] = await tx + .select({ n: sql`count(*)::int` }) + .from(users) + .where(and(eq(users.companyId, companyId), isNull(users.removedAt))); + if (members > PLAN_SPECS[plan].maxMembers) throw conflict('PLAN_TOO_SMALL'); + const same = plan === company.plan && model === company.billingModel; + const from = nextPeriodStart(company, now); + if (from === null) { + if (!same) await applyPlan(tx, company, plan, model, now); + return { from: null }; + } + await tx + .update(companies) + .set( + same + ? { plannedPlan: null, plannedBillingModel: null, plannedFrom: null } + : { plannedPlan: plan, plannedBillingModel: model, plannedFrom: from }, + ) + .where(eq(companies.id, companyId)); + // Счёт на следующий месяц мог быть уже выставлен по старому тарифу — он пересоздаётся. + await cancelPendingFixed(tx, companyId); + const [fresh] = await tx.select().from(companies).where(eq(companies.id, companyId)); + if (fresh!.billingModel === 'fixed') await ensureFixedInvoice(tx, fresh!, ymd(now)); + return { from: same ? null : from }; + }); +} + +// --- ежедневные задачи --- + +async function remind(tx: Tx, company: Company, now: Date) { + if (!company.subscriptionEndsAt || !company.accessEnabled) return; + const today = ymd(now); + const end = ymd(company.subscriptionEndsAt); + const state = accessState(company, now); + const left = daysBetween(today, end); + let key: string | null = null; + let text: [string, unknown[]] | null = null; + if (state === 'active' && REMIND_DAYS.includes(left)) { + key = `before:${left}:${end}`; + text = ['subEnding', [left, end]]; + } else if (state === 'overdue') { + key = `overdue:${today}`; + text = ['subOverdue', [Math.max(0, company.graceDays + left)]]; + } else if (state === 'restricted') { + key = `restricted:${end}`; + text = ['subRestricted', []]; + } + if (!key || !text || key === company.billingNotice) return; + await tx.update(companies).set({ billingNotice: key }).where(eq(companies.id, company.id)); + await enqueueText(tx, await managersOf(tx, company.id), text[0], text[1]); +} + +/** + * Запускается раз в несколько минут: счета по комиссии 1-го числа, запланированная смена тарифа, + * счёт на фикс за 7 дней до окончания, напоминания о подписке. Повторный запуск ничего не дублирует. + */ +export async function runBillingJobs(db: Db, now = new Date()) { + const today = ymd(now); + const hour = new Date(now.getTime() + 4 * 3_600_000).getUTCHours(); + const ids = await db.select({ id: companies.id }).from(companies).orderBy(companies.id); + for (const { id } of ids) { + await db.transaction(async (tx) => { + let [company] = await tx.select().from(companies).where(eq(companies.id, id)).for('update'); + if (!company?.subscriptionEndsAt) return; + // Счёт за прошлый месяц считается по тарифу, который действовал в том месяце, — до его смены. + if (company.billingModel === 'percent') await ensurePercentInvoice(tx, company, today); + if (company.plannedPlan && company.plannedFrom && company.plannedFrom <= today) { + const [{ n: members } = { n: 0 }] = await tx + .select({ n: sql`count(*)::int` }) + .from(users) + .where(and(eq(users.companyId, id), isNull(users.removedAt))); + const plan = company.plannedPlan as Plan; + if (members > PLAN_SPECS[plan].maxMembers) { + // Участников стало больше, чем вмещает новый тариф: смена отменяется. + await tx + .update(companies) + .set({ plannedPlan: null, plannedBillingModel: null, plannedFrom: null }) + .where(eq(companies.id, id)); + await enqueueText(tx, await managersOf(tx, id), 'planChangeCanceled'); + } else { + await applyPlan(tx, company, plan, (company.plannedBillingModel ?? company.billingModel) as BillingModel, now); + } + } + [company] = await tx.select().from(companies).where(eq(companies.id, id)); + if (company!.billingModel === 'fixed') await ensureFixedInvoice(tx, company!, today); + [company] = await tx.select().from(companies).where(eq(companies.id, id)); + if (hour >= REMIND_FROM_HOUR) await remind(tx, company!, now); + }); + } +} diff --git a/apps/api/src/cli/create-superadmin.ts b/apps/api/src/cli/create-superadmin.ts new file mode 100644 index 0000000..7d0414c --- /dev/null +++ b/apps/api/src/cli/create-superadmin.ts @@ -0,0 +1,25 @@ +// Создаёт суперадмина (владельца сервиса) или меняет ему пароль: +// pnpm --filter @lr/api superadmin "Имя" +import { randomBytes } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { hashPassword } from '../auth/password.ts'; +import { connect } from '../db/client.ts'; +import { users } from '../db/schema.ts'; +import { loadConfig } from '../lib/config.ts'; + +const [email, name = 'Суперадмин'] = process.argv.slice(2); +if (!email) throw new Error('Укажите email: superadmin "Имя"'); + +const { db, close } = connect(loadConfig().databaseUrl); +const password = randomBytes(9).toString('base64url'); +const passwordHash = await hashPassword(password); +const [existing] = await db.select().from(users).where(sql`lower(${users.email}) = ${email.toLowerCase()}`); +if (existing && existing.role !== 'superadmin') throw new Error('Этот email уже занят обычным пользователем'); +if (existing) await db.update(users).set({ passwordHash }).where(sql`${users.id} = ${existing.id}`); +else { + await db.insert(users).values({ + email: email.toLowerCase(), name, role: 'superadmin', passwordHash, emailVerifiedAt: new Date(), + }); +} +await close(); +console.log(`Суперадмин: ${email}\nПароль: ${password}`); diff --git a/apps/api/src/clients.int.test.ts b/apps/api/src/clients.int.test.ts new file mode 100644 index 0000000..4c6dd88 --- /dev/null +++ b/apps/api/src/clients.int.test.ts @@ -0,0 +1,698 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { runClientJobs } from './clients/service.ts'; +import { deals, leads, outbox, users } from './db/schema.ts'; +import { runReportJobs } from './reports.ts'; +import { processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// Сценарии воронки клиента: карточка, видимость, связь «клиент ↔ объект», общая сделка, +// настройка воронок руководителем. + +let t: Awaited>; +let tgId = 5000; +beforeAll(async () => { t = await testApp('clients'); }); +afterAll(() => t.close()); + +const tg = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); }; + +interface Member { api: ReturnType; id: number; chat: number } + +/** Компания Team с руководителем и агентами; у всех привязан Telegram. */ +const company = async (agents = 2) => { + const [sa] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + const created = await admin.post('/api/admin/companies', { + name: 'Агентство', plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const join = async (name: string, token: string): Promise => { + const api = await t.register(name); + await api.post(`/api/invites/${token}/accept`); + const id = (await api.get('/api/me')).body.user.id as number; + const chat = ++tgId; + await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id)); + return { api, id, chat }; + }; + const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!); + const invite = (await boss.api.post('/api/invites')).body.token as string; + const team: Member[] = []; + for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite)); + await flush(); + return { boss, agents: team, id: created.body.id as number }; +}; + +const pipeline = async (m: Member, name: 'object' | 'client') => { + const p = (await m.api.get(`/api/pipelines/${name}`)).body; + const status = (code: string) => p.statuses.find((s: any) => s.code === code).id as number; + return { ...p, status, names: p.statuses.map((s: any) => s.name) as string[] }; +}; + +const newClient = async (m: Member, over: object = {}) => + (await m.api.post('/api/clients', { name: 'Анна', phone: '091 123 456', kind: 'buyer', request: { districts: ['Кентрон'], priceTo: 200_000 }, ...over })).body.id as number; + +const newLead = async (m: Member, over: object = {}) => + (await m.api.post('/api/leads', { title: '3-комн. на Абовяна', dealType: 'sale', propertyType: 'apartment', district: 'Кентрон', price: 150_000, ...over })).body.id as number; + +const deal = { commission: 3000, currency: 'USD', date: '2026-10-08' }; + +describe('клиенты', () => { + it('карточка клиента: создание, запрос, правка, комментарий, история', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const id = await newClient(a, { source: 'Instagram' }); + const p = await pipeline(a, 'client'); + let card = (await a.api.get(`/api/clients/${id}`)).body; + expect(card).toMatchObject({ + name: 'Анна', phone: '+37491123456', kind: 'buyer', source: 'Instagram', assigneeId: a.id, + statusId: p.status('new'), request: { districts: ['Кентрон'], priceTo: 200_000 }, + }); + + expect((await a.api.patch(`/api/clients/${id}`, { name: 'Анна С.', source: '', request: { rooms: [2, 3] } })).status).toBe(200); + await a.api.post(`/api/clients/${id}/comments`, { text: 'Ищет рядом с метро' }); + card = (await a.api.get(`/api/clients/${id}`)).body; + expect(card).toMatchObject({ name: 'Анна С.', source: null, request: { rooms: [2, 3] } }); + expect(card.events.map((e: any) => e.type)).toEqual(['comment', 'edited', 'created']); + // Правка одного поля остальные не трогает. + await a.api.patch(`/api/clients/${id}`, { phone: '093 000 111' }); + expect((await a.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Анна С.', phone: '+37493000111', kind: 'buyer', request: { rooms: [2, 3] } }); + expect((await a.api.post('/api/clients', { name: '', kind: 'buyer' })).status).toBe(400); + }); + + it('видимость: чужого клиента агент не видит; при «видят все» видит без телефона и не может вести', async () => { + const c = await company(2); + const [a1, a2] = c.agents as [Member, Member]; + const id = await newClient(a1); + expect((await a2.api.get(`/api/clients/${id}`)).status).toBe(404); + expect((await a2.api.get('/api/clients?tab=all')).status).toBe(403); + expect((await a2.api.get('/api/clients?tab=mine')).body).toHaveLength(0); + expect((await c.boss.api.get('/api/clients?tab=all')).body).toMatchObject([{ id, phone: '+37491123456', assigneeName: 'Агент 1' }]); + // Поиск по телефону находит клиента руководителю, но не чужому агенту. + expect((await c.boss.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(1); + + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + const seen = (await a2.api.get(`/api/clients/${id}`)).body; + expect(seen).toMatchObject({ name: 'Анна', phone: null, phoneHidden: true }); + expect((await a2.api.get('/api/clients?tab=all&q=123456')).body).toHaveLength(0); + expect((await a2.api.post(`/api/clients/${id}/comments`, { text: 'x' })).body.code).toBe('NOT_YOUR_CLIENT'); + + // Клиент из другой компании недоступен. + const other = await company(0); + expect((await other.boss.api.get(`/api/clients/${id}`)).status).toBe(404); + }); + + it('воронка клиента: отказ требует причину, сделка — комиссию; закрытую сделку меняет руководитель', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const p = await pipeline(a, 'client'); + expect(p.names).toEqual(['Новый запрос', 'Квалификация', 'Подбор объектов', 'Показ', 'Переговоры и задаток', 'Сделка', 'Отказ']); + const id = await newClient(a); + const move = (body: object, who = a) => who.api.post(`/api/clients/${id}/status`, body); + + expect((await move({ statusId: p.statuses[1].id })).status).toBe(200); + expect((await move({ statusId: p.status('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED'); + // Причина из воронки объекта клиенту не подходит. + const objectReason = (await pipeline(a, 'object')).reasons[1].id; + expect((await move({ statusId: p.status('rejected'), rejectReasonId: objectReason })).body.code).toBe('REJECT_REASON_REQUIRED'); + expect((await move({ statusId: p.status('rejected'), rejectReasonId: p.reasons[0].id })).status).toBe(200); + expect((await a.api.get(`/api/clients/${id}`)).body.rejectReasonId).toBe(p.reasons[0].id); + + expect((await move({ statusId: p.status('deal') })).body.code).toBe('COMMISSION_REQUIRED'); + expect((await move({ statusId: p.status('deal'), deal })).status).toBe(200); + const card = (await a.api.get(`/api/clients/${id}`)).body; + expect(card).toMatchObject({ statusId: p.status('deal'), rejectReasonId: null, deal: { commission: 3000, currency: 'USD', date: '2026-10-08', leadId: null } }); + + // Сумму исправляет руководитель; агент — нет. + expect((await a.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 1 })).status).toBe(403); + expect((await c.boss.api.patch(`/api/deals/${card.deal.id}`, { ...deal, commission: 3500 })).status).toBe(200); + expect((await a.api.get(`/api/clients/${id}`)).body.deal.commission).toBe(3500); + + // Сделку отменяет только руководитель: запись сделки исчезает. + expect((await move({ statusId: p.statuses[1].id })).body.code).toBe('DEAL_CLOSED'); + expect((await move({ statusId: p.statuses[1].id }, c.boss)).status).toBe(200); + expect(await t.db.select().from(deals).where(eq(deals.clientId, id))).toHaveLength(0); + }); + + it('руководитель переназначает клиента; напоминание о следующем действии приходит в бот', async () => { + const c = await company(2); + const [a1, a2] = c.agents as [Member, Member]; + const id = await newClient(a1); + expect((await a1.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(403); + expect((await c.boss.api.post(`/api/clients/${id}/assign`, { userId: a2.id })).status).toBe(200); + await flush(); + expect(tg.inbox(a2.chat).at(-1)!.text).toBe('Вам назначен клиент: Анна'); + expect(tg.inbox(a1.chat).at(-1)!.text).toBe('Клиент «Анна» передан агенту Агент 2.'); + expect((await a1.api.get(`/api/clients/${id}`)).status).toBe(404); + const events = (await a2.api.get(`/api/clients/${id}`)).body.events; + expect(events[0]).toMatchObject({ type: 'assigned', oldValue: 'Агент 1', newValue: 'Агент 2' }); + + await a2.api.post(`/api/clients/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Позвонить' }); + expect(await runClientJobs(t.db)).toEqual({ reminded: 1 }); + expect(await runClientJobs(t.db)).toEqual({ reminded: 0 }); + await flush(); + expect(tg.inbox(a2.chat).at(-1)!.text).toBe('🔔 Напоминание по клиенту «Анна»: Позвонить'); + + // Удалённого агента сменяет наследник; без наследника клиентов забирает руководитель. + await c.boss.api.del(`/api/members/${a2.id}`); + expect((await c.boss.api.get(`/api/clients/${id}`)).body.assigneeId).toBe(c.boss.id); + }); + + it('ответственный — тот, кто создал клиента, даже если руководитель указал другого', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const res = await c.boss.api.post('/api/clients', { name: 'Ольга', kind: 'tenant', assigneeId: a.id }); + expect((await c.boss.api.get(`/api/clients/${res.body.id}`)).body.assigneeId).toBe(c.boss.id); + }); +}); + +describe('связь «клиент ↔ объект» и общая сделка', () => { + it('объект прикрепляется к клиенту: «предложен» → «показан»; в карточке объекта видны клиенты', async () => { + const c = await company(2); + const [a1, a2] = c.agents as [Member, Member]; + const clientId = await newClient(a1); + const leadId = await newLead(a1); + const foreign = await newLead(a2); + + // Подбор: подходит объект в Кентроне до 200 000; дорогой и арендный — нет. + await newLead(a1, { title: 'Дорогая', price: 500_000 }); + await newLead(a1, { title: 'Аренда', dealType: 'rent', price: 800 }); + const suggested = (await a1.api.get(`/api/clients/${clientId}/suggestions`)).body; + expect(suggested.map((l: any) => l.id)).toEqual([leadId]); + + expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId })).status).toBe(200); + // Чужой объект при закрытой видимости прикрепить нельзя. + expect((await a1.api.post(`/api/clients/${clientId}/leads`, { leadId: foreign })).status).toBe(404); + expect((await a1.api.get(`/api/clients/${clientId}/suggestions`)).body).toHaveLength(0); + await a1.api.post(`/api/clients/${clientId}/leads`, { leadId, state: 'shown' }); + + const card = (await a1.api.get(`/api/clients/${clientId}`)).body; + expect(card.leads).toMatchObject([{ id: leadId, title: '3-комн. на Абовяна', state: 'shown' }]); + expect(card.leads[0].shownAt).toBeTruthy(); + expect(card.events.slice(0, 2).map((e: any) => [e.type, e.newValue])).toEqual([['shown', '3-комн. на Абовяна'], ['offered', '3-комн. на Абовяна']]); + + const lead = (await a1.api.get(`/api/leads/${leadId}`)).body; + expect(lead.clients).toMatchObject([{ id: clientId, name: 'Анна', state: 'shown' }]); + expect(lead.events[0]).toMatchObject({ type: 'client_shown', newValue: 'Анна' }); + expect((await a1.api.get('/api/clients-options')).body).toMatchObject([{ id: clientId, name: 'Анна' }]); + + expect((await a1.api.del(`/api/clients/${clientId}/leads/${leadId}`)).status).toBe(200); + expect((await a1.api.get(`/api/clients/${clientId}`)).body.leads).toHaveLength(0); + expect((await a1.api.get(`/api/leads/${leadId}`)).body.clients).toHaveLength(0); + }); + + it('сделка объекта с клиентом из CRM закрывается один раз: оба в «Сделке», комиссия одна', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const po = await pipeline(a, 'object'); + const pc = await pipeline(a, 'client'); + const count = async () => (await t.db.select().from(deals).where(eq(deals.companyId, c.id))).length; + + // Со стороны объекта. + const client1 = await newClient(a); + const lead1 = await newLead(a); + const close = (leadId: number, clientId: number) => + a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal: { ...deal, clientId } }); + // Неприкреплённого клиента в пару взять нельзя — и объект при этом остаётся в прежнем статусе. + expect((await close(lead1, client1)).body.code).toBe('PAIR_NOT_LINKED'); + expect((await a.api.get(`/api/leads/${lead1}`)).body.statusId).toBe(po.status('taken')); + await a.api.post(`/api/clients/${client1}/leads`, { leadId: lead1 }); + expect((await close(lead1, client1)).status).toBe(200); + expect(await count()).toBe(1); + const leadCard = (await a.api.get(`/api/leads/${lead1}`)).body; + const clientCard = (await a.api.get(`/api/clients/${client1}`)).body; + expect(leadCard).toMatchObject({ statusId: po.status('deal'), deal: { commission: 3000, clientId: client1, clientName: 'Анна' } }); + expect(clientCard).toMatchObject({ statusId: pc.status('deal'), deal: { id: leadCard.deal.id, commission: 3000, leadId: lead1 } }); + expect(clientCard.events[0]).toMatchObject({ type: 'deal', text: '3000 USD' }); + + // Со стороны клиента. + const client2 = await newClient(a, { name: 'Давид' }); + const lead2 = await newLead(a); + await a.api.post(`/api/clients/${client2}/leads`, { leadId: lead2, state: 'shown' }); + expect((await a.api.post(`/api/clients/${client2}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 5000, leadId: lead2 } })).status).toBe(200); + expect(await count()).toBe(2); + expect((await a.api.get(`/api/leads/${lead2}`)).body).toMatchObject({ statusId: po.status('deal'), deal: { commission: 5000, clientId: client2 } }); + + // Второй клиент к уже закрытому объекту не подставляется. + const client3 = await newClient(a, { name: 'Мария' }); + await a.api.post(`/api/clients/${client3}/leads`, { leadId: lead2 }); + expect((await a.api.post(`/api/clients/${client3}/status`, { statusId: pc.status('deal'), deal: { ...deal, leadId: lead2 } })).body.code).toBe('DEAL_HAS_PAIR'); + expect((await a.api.get(`/api/clients/${client3}`)).body.statusId).toBe(pc.status('new')); + + // Руководитель отменил сделку у объекта: запись остаётся за клиентом, комиссия не теряется и не двоится. + expect((await c.boss.api.post(`/api/leads/${lead2}/status`, { statusId: po.status('taken') })).status).toBe(200); + expect(await count()).toBe(2); + expect((await a.api.get(`/api/leads/${lead2}`)).body.deal).toBeNull(); + expect((await a.api.get(`/api/clients/${client2}`)).body.deal).toMatchObject({ commission: 5000, leadId: null }); + }); + + it('объект и клиент закрыты порознь, затем сведены в пару: записи сливаются в одну', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const po = await pipeline(a, 'object'); + const pc = await pipeline(a, 'client'); + const clientId = await newClient(a); + const leadId = await newLead(a); + await a.api.post(`/api/clients/${clientId}/leads`, { leadId }); + await a.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('deal'), deal }); + await a.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal }); + expect(await t.db.select().from(deals).where(eq(deals.companyId, c.id))).toHaveLength(2); + + // Руководитель возвращает клиента на этап и закрывает заново — уже с объектом. + await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.statuses[1].id }); + expect((await c.boss.api.post(`/api/clients/${clientId}/status`, { statusId: pc.status('deal'), deal: { ...deal, commission: 4000, leadId } })).status).toBe(200); + const rows = await t.db.select().from(deals).where(eq(deals.companyId, c.id)); + expect(rows).toMatchObject([{ leadId, clientId, commission: 4000 }]); + }); +}); + +describe('настройка воронок', () => { + it('руководитель добавляет, переименовывает, переставляет и удаляет промежуточные статусы', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + expect((await a.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).status).toBe(403); + + const added = (await c.boss.api.post('/api/pipelines/object/statuses', { name: 'Оценка' })).body.id as number; + let p = await pipeline(c.boss, 'object'); + expect(p.names).toEqual(['Новый', 'Взят в работу', 'Дозвонился', 'Встреча с собственником', 'Договор (эксклюзив)', 'В продаже (идут показы)', 'Оценка', 'Сделка', 'Отказ']); + + await c.boss.api.patch(`/api/pipeline-statuses/${added}`, { name: 'Оценка объекта', poolReturn: true }); + const middle = p.statuses.filter((s: any) => !s.code).map((s: any) => s.id) as number[]; + const order = [added, ...middle.filter((id) => id !== added)]; + expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order })).status).toBe(200); + // Неполный список и системные статусы в порядке не принимаются. + expect((await c.boss.api.post('/api/pipelines/object/order', { ids: order.slice(1) })).status).toBe(400); + expect((await c.boss.api.post('/api/pipelines/object/order', { ids: [p.status('deal'), ...order.slice(1)] })).status).toBe(400); + p = await pipeline(c.boss, 'object'); + expect(p.names.slice(0, 4)).toEqual(['Новый', 'Взят в работу', 'Оценка объекта', 'Дозвонился']); + expect(p.statuses[2]).toMatchObject({ poolReturn: true, count: 0 }); + // Агент видит новую воронку. + expect((await pipeline(a, 'object')).names[2]).toBe('Оценка объекта'); + + // Системный статус удалить нельзя; занятый — только с переводом карточек. + expect((await c.boss.api.del(`/api/pipeline-statuses/${p.status('taken')}`)).body.code).toBe('SYSTEM_STATUS'); + const leadId = await newLead(a); + await a.api.post(`/api/leads/${leadId}/status`, { statusId: added }); + expect((await pipeline(c.boss, 'object')).statuses[2].count).toBe(1); + expect((await c.boss.api.del(`/api/pipeline-statuses/${added}`)).body.code).toBe('STATUS_IN_USE'); + expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${p.status('deal')}`)).body.code).toBe('BAD_STATUS'); + expect((await c.boss.api.del(`/api/pipeline-statuses/${added}?moveTo=${middle[0]}`)).status).toBe(200); + const card = (await a.api.get(`/api/leads/${leadId}`)).body; + expect(card.statusId).toBe(middle[0]); + expect(card.events[0]).toMatchObject({ type: 'status', oldValue: 'Оценка объекта', newValue: 'Дозвонился' }); + expect((await pipeline(c.boss, 'object')).names).toHaveLength(8); + expect((await a.api.post(`/api/leads/${leadId}/status`, { statusId: added })).body.code).toBe('BAD_STATUS'); + + // Воронка клиента настраивается отдельно; возврата в пул у неё нет. + const cs = (await c.boss.api.post('/api/pipelines/client/statuses', { name: 'Ипотека' })).body.id as number; + expect((await pipeline(c.boss, 'client')).names.slice(-3)).toEqual(['Ипотека', 'Сделка', 'Отказ']); + expect((await c.boss.api.patch(`/api/pipeline-statuses/${cs}`, { poolReturn: true })).status).toBe(400); + }); + + it('причины отказа: добавить, переименовать, переставить, удалить; системную и последнюю — нельзя', async () => { + const c = await company(1); + const [a] = c.agents as [Member]; + const made = (await c.boss.api.post('/api/pipelines/client/reasons', { name: 'Уехал из страны' })).body.id as number; + await c.boss.api.patch(`/api/reject-reasons/${made}`, { name: 'Уехал' }); + let p = await pipeline(a, 'client'); + expect(p.reasons.at(-1)).toMatchObject({ id: made, name: 'Уехал' }); + const ids = [made, ...p.reasons.filter((r: any) => r.id !== made).map((r: any) => r.id)]; + expect((await c.boss.api.post('/api/pipelines/client/reasons-order', { ids })).status).toBe(200); + expect((await pipeline(a, 'client')).reasons[0].id).toBe(made); + + // Клиент закрыт с причиной, причину потом удалили: в карточке она остаётся. + const clientId = await newClient(a); + await a.api.post(`/api/clients/${clientId}/status`, { statusId: p.status('rejected'), rejectReasonId: made }); + expect((await a.api.del(`/api/reject-reasons/${made}`)).status).toBe(403); + expect((await c.boss.api.del(`/api/reject-reasons/${made}`)).status).toBe(200); + expect((await a.api.get(`/api/clients/${clientId}`)).body.rejectReasonId).toBe(made); + p = await pipeline(a, 'client'); + expect(p.reasons.some((r: any) => r.id === made)).toBe(false); + + const po = await pipeline(c.boss, 'object'); + const agentReason = po.reasons.find((r: any) => r.code === 'agent').id; + expect((await c.boss.api.del(`/api/reject-reasons/${agentReason}`)).body.code).toBe('SYSTEM_REASON'); + const rest = po.reasons.filter((r: any) => !r.code); + for (const r of rest.slice(1)) expect((await c.boss.api.del(`/api/reject-reasons/${r.id}`)).status).toBe(200); + expect((await c.boss.api.del(`/api/reject-reasons/${rest[0].id}`)).body.code).toBe('LAST_REASON'); + }); + + it('кто будет жить: состав, дети, животные; подбор не предлагает объект, где с ними нельзя', async () => { + const c = await company(); + const agent = c.agents[0]!; + const created = await agent.api.post('/api/clients', { + name: 'Семья Саркисян', kind: 'tenant', request: {}, + household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' } }, + }); + expect(created.status).toBe(200); + const id = created.body.id as number; + expect((await agent.api.get(`/api/clients/${id}`)).body.household).toEqual({ + who: 'couple', children: { count: 2, age: '3 и 7 лет' }, pets: { count: 1, age: 'кот, 2 года' }, + }); + expect((await agent.api.post('/api/clients', { name: 'Х', kind: 'tenant', household: { children: { count: 0 } } })).body.code).toBe('VALIDATION'); + + // Три объекта в аренду: с детьми нельзя; с животными нельзя; про правила ничего не сказано. + const lead = async (title: string, features: object | null) => { + const made = await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment' }); + await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, made.body.id)); + return title; + }; + await lead('Без детей', { children: 'no', amenities: [], attrs: [] }); + await lead('Без животных', { children: 'yes', pets: 'no', amenities: [], attrs: [] }); + await lead('Можно всем', { children: 'yes', pets: 'negotiable', amenities: [], attrs: [] }); + await lead('Не указано', null); + const titles = async () => ((await agent.api.get(`/api/clients/${id}/suggestions`)).body as any[]).map((x) => x.title).sort(); + expect(await titles()).toEqual(['Можно всем', 'Не указано']); + + // Правка: животных больше нет — объект «без животных» появляется; остальное в карточке не затёрто. + await agent.api.patch(`/api/clients/${id}`, { household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } } }); + expect(await titles()).toEqual(['Без животных', 'Можно всем', 'Не указано']); + await agent.api.patch(`/api/clients/${id}`, { household: null }); + expect(await titles()).toHaveLength(4); + expect((await agent.api.get(`/api/clients/${id}`)).body).toMatchObject({ name: 'Семья Саркисян', household: null }); + }); + + it('фото к клиенту: загрузка, просмотр, удаление; чужой агент и неподходящий файл получают отказ', async () => { + const c = await company(); + const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]]; + const id = (await agent.api.post('/api/clients', { name: 'С фото', kind: 'tenant' })).body.id as number; + const login = async (m: typeof agent) => { + const email = (await m.api.get('/api/me')).body.user.email as string; + return (await t.client().post('/api/auth/login', { email, password: 'password-123' })).body.token as string; + }; + const upload = async (token: string, type: string, body: Buffer) => + t.app.inject({ method: 'POST', url: `/api/clients/${id}/photos?type=${encodeURIComponent(type)}`, payload: body, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' } }); + const mine = await login(agent); + const png = Buffer.from('89504e470d0a1a0a', 'hex'); + + const ok = await upload(mine, 'image/png', png); + expect(ok.statusCode).toBe(200); + const photoId = ok.json().id as number; + expect((await upload(mine, 'application/pdf', png)).json().code).toBe('PHOTO_TYPE'); + expect((await upload(mine, 'image/png', Buffer.alloc(0))).statusCode).toBe(400); + expect((await upload(await login(other), 'image/png', png)).statusCode).toBeGreaterThanOrEqual(403); + + expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toMatchObject([{ id: photoId }]); + const file = await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${mine}` } }); + expect(file.headers['content-type']).toBe('image/png'); + expect(file.rawPayload.equals(png)).toBe(true); + // Руководитель видит фото; агент другой компании — нет. + expect((await c.boss.api.get(`/api/clients/${id}`)).body.photos).toHaveLength(1); + const stranger = await company(); + expect((await t.app.inject({ method: 'GET', url: `/api/clients/${id}/photos/${photoId}`, headers: { authorization: `Bearer ${await login(stranger.boss)}` } })).statusCode).toBe(404); + + expect((await agent.api.del(`/api/clients/${id}/photos/${photoId}`)).status).toBe(200); + expect((await agent.api.get(`/api/clients/${id}`)).body.photos).toEqual([]); + }); + + it('комиссия агентства: руководитель задаёт условия, агент меняет их в карточке', async () => { + const c = await company(); + const [agent, other] = c.agents as [typeof c.agents[0], typeof c.agents[0]]; + // Условия по умолчанию задаёт только руководитель. + const terms = { + objectSale: { mode: 'percent', value: 3, currency: 'USD' }, + objectRent: { mode: 'percent', value: 50, currency: 'USD' }, + clientBuyer: { mode: 'fixed', value: 1000, currency: 'USD' }, + clientTenant: null, + }; + expect((await agent.api.patch('/api/company', { commission: terms })).status).toBe(403); + expect((await c.boss.api.patch('/api/company', { commission: terms })).status).toBe(200); + expect((await c.boss.api.get('/api/me')).body.company.settings.commission).toEqual(terms); + // Остальные настройки компании при этом не тронуты. + expect((await c.boss.api.get('/api/me')).body.company.settings.poolReturnHours).toBe(24); + + // Объект на продажу: 3 % от цены; в аренду — 50 % месячной платы, в валюте объекта. + const sale = await newLead(agent, { price: 150_000, currency: 'USD' }); + const rent = await newLead(agent, { dealType: 'rent', price: 400_000, currency: 'AMD' }); + expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toEqual({ rule: terms.objectSale, custom: false, expected: { amount: 4500, currency: 'USD' } }); + expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 200_000, currency: 'AMD' }); + + // Агент договорился на 2,5 % по этому объекту; чужой агент условие менять не может. + expect((await other.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 1 } })).status).toBeGreaterThanOrEqual(403); + expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: 2.5 } })).status).toBe(200); + expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: true, rule: { mode: 'percent', value: 2.5 }, expected: { amount: 3750, currency: 'USD' } }); + expect((await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'percent', value: -1 } })).body.code).toBe('VALIDATION'); + // Сброс возвращает условие компании. + await agent.api.post(`/api/leads/${sale}/commission`, { rule: null }); + expect((await agent.api.get(`/api/leads/${sale}`)).body.commission).toMatchObject({ custom: false, expected: { amount: 4500 } }); + + // Фикс + процент: $200 и 2 % от цены; в валюте цены — фикс приводится по курсу. + await agent.api.post(`/api/leads/${sale}/commission`, { rule: { mode: 'mixed', value: 2, fixed: 200, currency: 'USD' } }); + expect((await agent.api.get(`/api/leads/${sale}`)).body.commission.expected).toEqual({ amount: 3200, currency: 'USD' }); + await agent.api.post(`/api/leads/${rent}/commission`, { rule: { mode: 'mixed', value: 50, fixed: 100, currency: 'USD' } }); + expect((await agent.api.get(`/api/leads/${rent}`)).body.commission.expected).toEqual({ amount: 238_500, currency: 'AMD' }); + await agent.api.post(`/api/leads/${sale}/commission`, { rule: null }); + + // История объекта: кто, с какого на какое условие; сброс — возврат к условию компании. Повтор того же значения записи не даёт. + await agent.api.post(`/api/leads/${sale}/commission`, { rule: null }); + const history = ((await agent.api.get(`/api/leads/${sale}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse(); + expect(history.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([ + // 3 % → 2,5 % от той же цены: ожидаемая сумма −16,7 %; возврат к 3 %: +20 %. + ['Агент 1', 'percent:3', 'percent:2.5', '|-16.7'], + ['Агент 1', 'percent:2.5', null, 'percent:3|20'], + ['Агент 1', 'percent:3', 'mixed:200:USD:2', '|-28.9'], + ['Агент 1', 'mixed:200:USD:2', null, 'percent:3|40.6'], + ]); + + // Клиенты: покупателю — фикс; арендатору условие не задано, агент ставит своё — процент от бюджета. + const buyer = await newClient(agent); + expect((await agent.api.get(`/api/clients/${buyer}`)).body.commission).toEqual({ rule: terms.clientBuyer, custom: false, expected: { amount: 1000, currency: 'USD' } }); + const tenant = await newClient(agent, { kind: 'tenant', request: { priceTo: 600_000, priceCurrency: 'AMD' } }); + expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toEqual({ rule: null, custom: false, expected: null }); + await agent.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'percent', value: 50 } }); + expect((await agent.api.get(`/api/clients/${tenant}`)).body.commission).toMatchObject({ custom: true, expected: { amount: 300_000, currency: 'AMD' } }); + expect((await other.api.post(`/api/clients/${tenant}/commission`, { rule: null })).status).toBeGreaterThanOrEqual(403); + // Руководитель поправил условие клиента — в истории клиента его имя и оба значения. + await c.boss.api.post(`/api/clients/${tenant}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } }); + const clientHistory = ((await agent.api.get(`/api/clients/${tenant}`)).body.events as any[]).filter((e) => e.type === 'commission').reverse(); + // Было не задано — сравнивать не с чем. 50 % от 600 000 драм (≈ $779) → фикс $300: −61,5 %. + expect(clientHistory.map((e) => [e.userName, e.oldValue, e.newValue, e.text])).toEqual([ + ['Агент 1', null, 'percent:50', '|'], + ['Руководитель', 'percent:50', 'fixed:300:USD', '|-61.5'], + ]); + }); + + it('оплата агентов: оклад, процент со сделок или оклад + процент; руководитель видит, кому сколько платить', async () => { + const c = await company(); + const [a1, a2] = c.agents as [typeof c.agents[0], typeof c.agents[0]]; + const p = await pipeline(c.boss, 'object'); + // Условия задаёт только руководитель. + expect((await a1.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 90 } })).status).toBe(403); + expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD' } })).status).toBe(200); + expect((await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 50, currency: 'AMD' } })).status).toBe(200); + expect((await c.boss.api.post(`/api/members/${c.boss.id}/pay`, { pay: { mode: 'fixed', fixed: 1000, currency: 'USD' } })).status).toBe(200); + expect((await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 150 } })).body.code).toBe('VALIDATION'); + expect((await c.boss.api.post('/api/members/999999/pay', { pay: null })).status).toBe(404); + // Руководитель видит условия всех, агент — только свои. + expect(((await c.boss.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode)).toEqual(['fixed', 'mixed', 'percent']); + expect(((await a1.api.get('/api/members')).body as any[]).map((m) => m.pay?.mode ?? null)).toEqual([null, 'mixed', null]); + + // Сделки этого месяца: у первого агента $3000 и 385 000 драм (= $1000), у второго $2000. + const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10); + const close = async (m: Member, commission: number, currency: string, date = today) => { + const id = await newLead(m); + await m.api.post(`/api/leads/${id}/status`, { statusId: p.status('deal'), deal: { commission, currency, date } }); + }; + await close(a1, 3000, 'USD'); + await close(a1, 385_000, 'AMD'); + await close(a2, 2000, 'USD'); + // Сделка прошлого года в текущий месяц не попадает. + await close(a2, 9000, 'USD', '2020-01-15'); + + const month = (await c.boss.api.get('/api/payroll')).body; + expect(month.months).toBe(1); + const row = (id: number) => month.agents.find((x: any) => x.userId === id); + // Оклад $300 + 30 % от $4000 = $1500. + expect(row(a1.id)).toMatchObject({ deals: 2, commission: 4000, currency: 'USD', fixed: 300, share: 1200, total: 1500, byCurrency: { USD: 3000, AMD: 385_000 } }); + // 50 % от $2000, выплата в драмах: 385 000. + expect(row(a2.id)).toMatchObject({ deals: 1, commission: 770_000, currency: 'AMD', fixed: 0, share: 385_000, total: 385_000 }); + expect(row(c.boss.id)).toMatchObject({ deals: 0, fixed: 1000, share: 0, total: 1000 }); + + // Свой период: полгода 2020-го — шесть окладов и старая сделка. + const old = (await c.boss.api.get('/api/payroll?from=2020-01-01&to=2020-06-30')).body; + expect(old.months).toBe(6); + expect(old.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 0, fixed: 1800, total: 1800 }); + expect(old.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, share: 1_732_500 }); + + // Агент видит свои условия и начисления, чужие выплаты — нет. + expect((await a1.api.get('/api/payroll')).status).toBe(403); + expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ userId: a1.id, pay: { mode: 'mixed' }, deals: 2, total: 1500 }); + // Условия сняли — начислений нет. + await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: null }); + expect((await a1.api.get('/api/my-pay')).body).toMatchObject({ pay: null, total: 0, deals: 2 }); + }); + + it('комиссия из двух частей: с собственника — агенту объекта, с клиента — агенту клиента; процент аренды свой', async () => { + const c = await company(); + const [a1, a2] = c.agents as [Member, Member]; + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + const po = await pipeline(c.boss, 'object'); + const pc = await pipeline(c.boss, 'client'); + await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { mode: 'percent', percent: 40, percentRent: 50, currency: 'USD' } }); + await c.boss.api.post(`/api/members/${a2.id}/pay`, { pay: { mode: 'percent', percent: 30, currency: 'USD' } }); + const today = new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10); + + // Объект в аренду ведёт первый агент, арендатора — второй. Сделку закрывает второй. + const leadId = await newLead(a1, { dealType: 'rent', price: 1000 }); + const clientId = await newClient(a2, { kind: 'tenant' }); + await a2.api.post(`/api/clients/${clientId}/leads`, { leadId }); + const body = { statusId: pc.status('deal'), deal: { ownerCommission: 400, clientCommission: 600, currency: 'USD', date: today, leadId } }; + // Сделка без комиссии не закрывается. + expect((await a2.api.post(`/api/clients/${clientId}/status`, { ...body, deal: { ...body.deal, ownerCommission: 0, clientCommission: 0 } })).body.code).toBe('COMMISSION_REQUIRED'); + expect((await a2.api.post(`/api/clients/${clientId}/status`, body)).status).toBe(200); + expect((await a1.api.get(`/api/leads/${leadId}`)).body.deal).toMatchObject({ + commission: 1000, ownerCommission: 400, clientCommission: 600, ownerAgentName: 'Агент 1', clientAgentName: 'Агент 2', kind: 'rent', + }); + + const rows = async () => (await c.boss.api.get('/api/payroll')).body.agents as any[]; + const row = async (id: number) => (await rows()).find((x) => x.userId === id); + // Первому — 50 % (аренда) от $400, второму — 30 % (общий процент) от $600. + expect(await row(a1.id)).toMatchObject({ deals: 1, commission: 400, commissionRent: 400, commissionSale: 0, shareRent: 200, share: 200, total: 200 }); + expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 600, share: 180, total: 180 }); + + // Список сделок агента: руководителю — любого, агенту — только свои. + const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}`)).body; + expect(list.deals).toEqual([ + expect.objectContaining({ kind: 'rent', side: 'owner', partner: 'Агент 2', leadId, clientId, clientName: 'Анна', dealCommission: 1000, part: 400, percent: 50, share: 200 }), + ]); + const own = (await a2.api.get(`/api/payroll/deals?userId=${a1.id}`)).body; + expect(own).toMatchObject({ userId: a2.id, deals: [{ side: 'client', partner: 'Агент 1', part: 600, percent: 30, share: 180 }] }); + + // В аналитике сделка одна, комиссия разнесена по агентам. + const an = (await c.boss.api.get(`/api/analytics?pipeline=object&from=${today}&to=${today}`)).body; + expect(an.totals).toMatchObject({ deals: 1, commission: { USD: 1000 } }); + expect(an.agents.find((x: any) => x.userId === a1.id)).toMatchObject({ deals: 1, commission: { USD: 400 } }); + expect(an.agents.find((x: any) => x.userId === a2.id)).toMatchObject({ deals: 1, commission: { USD: 600 } }); + + // Руководитель правит части; сумма одной строкой остаётся на прежней стороне. + const dealId = list.deals[0].id; + expect((await a1.api.patch(`/api/deals/${dealId}`, { ownerCommission: 1, clientCommission: 1, currency: 'USD', date: today })).status).toBe(403); + expect((await c.boss.api.patch(`/api/deals/${dealId}`, { ownerCommission: 500, clientCommission: 700, currency: 'USD', date: today })).status).toBe(200); + expect(await row(a1.id)).toMatchObject({ commission: 500, share: 250 }); + expect(await row(a2.id)).toMatchObject({ commission: 700, share: 210 }); + + // Объект вышел из сделки — запись остаётся за клиентом, обе части получает его агент. + await c.boss.api.post(`/api/leads/${leadId}/status`, { statusId: po.status('taken') }); + expect(await row(a1.id)).toMatchObject({ deals: 0, commission: 0, total: 0 }); + expect(await row(a2.id)).toMatchObject({ deals: 1, commission: 1200, share: 360 }); + + // Продажа одной суммой, как раньше: вся комиссия — агенту, который закрыл свой объект. + const sale = await newLead(a1); + await a1.api.post(`/api/leads/${sale}/status`, { statusId: po.status('deal'), deal: { commission: 2000, currency: 'USD', date: today } }); + expect(await row(a1.id)).toMatchObject({ deals: 1, commissionSale: 2000, commissionRent: 0, shareSale: 800, total: 800 }); + }); + + it('план и бонус, ручные начисления, «выплачено»: закрытый месяц не пересчитывается, разница уходит в следующий', async () => { + const c = await company(1); + const [a1] = c.agents as [Member]; + const po = await pipeline(c.boss, 'object'); + const ym = (back: number) => { + const d = new Date(Date.now() + 4 * 3_600_000); + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - back, 1)).toISOString().slice(0, 7); + }; + const [m1, m2, m3] = [ym(2), ym(1), ym(0)]; + const range = (m: string) => { + const [y, mm] = m.split('-').map(Number) as [number, number]; + return `from=${m}-01&to=${m}-${new Date(Date.UTC(y, mm, 0)).getUTCDate()}`; + }; + const row = async (m: string) => ((await c.boss.api.get(`/api/payroll?${range(m)}`)).body.agents as any[]).find((x) => x.userId === a1.id); + const close = async (commission: number, month: string) => { + const id = await newLead(a1); + await a1.api.post(`/api/leads/${id}/status`, { statusId: po.status('deal'), deal: { commission, currency: 'USD', date: `${month}-10` } }); + }; + const terms = { mode: 'mixed', fixed: 300, percent: 30, currency: 'USD', planDeals: 2, planCommission: 3000, bonusMode: 'fixed', bonusValue: 200 }; + await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: terms }); + + // Одна сделка — план не выполнен; две на $3500 — выполнен, бонус $200. + await close(2000, m1); + expect(await row(m1)).toMatchObject({ deals: 1, share: 600, planDone: false, bonus: 0, total: 900 }); + await close(1500, m1); + expect(await row(m1)).toMatchObject({ deals: 2, commission: 3500, share: 1050, planDone: true, bonus: 200, accrued: 1550, total: 1550 }); + + // Ручные записи: премия и аванс. Агент их не ставит. + const adjust = (m: Member, amount: number, note: string, month = m1) => m.api.post('/api/payroll/adjustments', { userId: a1.id, month, amount, note }); + expect((await adjust(a1, 500, 'себе')).status).toBe(403); + expect((await adjust(c.boss, 100, 'премия')).status).toBe(200); + const advance = (await adjust(c.boss, -50, 'аванс')).body.id; + expect((await adjust(c.boss, 0, 'ноль')).body.code).toBe('VALIDATION'); + expect((await adjust(c.boss, 10, 'будущее', '2999-01')).body.code).toBe('VALIDATION'); + expect(await row(m1)).toMatchObject({ adjustmentsTotal: 50, total: 1600, payout: null, adjustments: [{ amount: 100, note: 'премия' }, { amount: -50, note: 'аванс' }] }); + + // «Выплачено»: месяц закрыт, ручные записи и повторная отметка — нельзя. + const pay = (month: string, extra: object = {}) => c.boss.api.post('/api/payroll/payouts', { userId: a1.id, month, ...extra }); + expect((await a1.api.post('/api/payroll/payouts', { userId: a1.id, month: m1 })).status).toBe(403); + const first = (await pay(m1, { paidAt: `${m2}-05` })).body.id; + expect(await row(m1)).toMatchObject({ total: 1600, debt: 0, payout: { id: first, amount: 1600, currency: 'USD', paidAt: `${m2}-05`, last: true } }); + expect((await pay(m1)).body.code).toBe('MONTH_PAID'); + expect((await adjust(c.boss, 10, 'поздно')).body.code).toBe('MONTH_PAID'); + expect((await c.boss.api.del(`/api/payroll/adjustments/${advance}`)).body.code).toBe('MONTH_PAID'); + + // Условия поменяли — закрытый месяц считается по прежним. + await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, percent: 50 } }); + expect(await row(m1)).toMatchObject({ pay: { percent: 30 }, share: 1050, total: 1600, debt: 0 }); + expect(await row(m2)).toMatchObject({ pay: { percent: 50 }, fixed: 300, recalc: [], total: 300 }); + + // Сделку закрытого месяца исправили: +$1000 комиссии = +$300 агенту. Разница — в следующем месяце. + const list = (await c.boss.api.get(`/api/payroll/deals?userId=${a1.id}&${range(m1)}`)).body.deals as any[]; + await c.boss.api.patch(`/api/deals/${list[1].id}`, { commission: 2500, currency: 'USD', date: `${m1}-10` }); + expect(await row(m1)).toMatchObject({ share: 1350, total: 1900, debt: 300, payout: { amount: 1600 } }); + expect(await row(m2)).toMatchObject({ recalc: [{ month: m1, amount: 300 }], recalcTotal: 300, total: 600 }); + + // Заплатили меньше начисленного — остаток идёт дальше; долг первого месяца погашен. + const second = (await pay(m2, { amount: 500, note: 'частично' })).body.id; + expect(await row(m1)).toMatchObject({ debt: 0, payout: { last: false } }); + expect(await row(m2)).toMatchObject({ total: 600, debt: 100, payout: { amount: 500, note: 'частично', last: true } }); + expect(await row(m3)).toMatchObject({ recalc: [{ month: m2, amount: 100 }], total: 400 }); + + // История выплат: видна руководителю и самому агенту. + expect((await a1.api.get('/api/payroll/payouts')).body).toMatchObject([{ month: m2, amount: 500, accrued: 600 }, { month: m1, amount: 1600, accrued: 1600 }]); + expect(((await c.boss.api.get(`/api/payroll/payouts?userId=${a1.id}`)).body as any[]).length).toBe(2); + + // Отменить можно только последнюю отметку; после отмены разница снова ждёт в открытом месяце. + expect((await c.boss.api.del(`/api/payroll/payouts/${first}`)).body.code).toBe('PAYOUT_NOT_LAST'); + expect((await c.boss.api.del(`/api/payroll/payouts/${second}`)).status).toBe(200); + expect(await row(m2)).toMatchObject({ payout: null, recalc: [{ month: m1, amount: 300 }], total: 600 }); + expect(await row(m3)).toMatchObject({ recalc: [], total: 300 }); + + // Надбавка к проценту за план — на всю комиссию агента за месяц: 10 % от $4000. + await c.boss.api.post(`/api/members/${a1.id}/pay`, { pay: { ...terms, planDeals: 1, planCommission: null, bonusMode: 'percent', bonusValue: 10 } }); + await close(4000, m3); + expect(await row(m3)).toMatchObject({ share: 1200, planDone: true, bonus: 400, total: 1900 }); + // Рейтинг виден агенту: место, сделки, выполнение плана — без денег. + const board = (await a1.api.get(`/api/rating?month=${m3}`)).body; + expect(board.agents[0]).toEqual({ place: 1, userId: a1.id, name: 'Агент 1', deals: 1, planPercent: 100 }); + expect(board.agents[1]).toMatchObject({ place: 2, userId: c.boss.id, deals: 0, planPercent: null }); + // Динамика: руководителю — по всем, агенту — только своя; прошлые месяцы на месте. + const trend = (await c.boss.api.get('/api/payroll/trend?months=3')).body; + expect(trend.months).toEqual([m1, m2, m3]); + expect(trend.agents.find((x: any) => x.userId === a1.id).points).toEqual([{ deals: 2, commission: 4500 }, { deals: 0, commission: 0 }, { deals: 1, commission: 4000 }]); + expect(trend.totals.map((x: any) => x.deals)).toEqual([2, 0, 1]); + expect(((await a1.api.get('/api/payroll/trend?months=3')).body.agents as any[]).map((x) => x.userId)).toEqual([a1.id]); + // Отчёты в Telegram: выключены — не уходят; включены — раз за период, первого числа и в понедельник после 9 утра. + const reports = async () => (await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id))).map((j) => (j.payload as any).raw as string).filter(Boolean); + const firstDay = new Date(`${m3}-01T06:00:00Z`); + expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0); + await c.boss.api.patch('/api/company', { reportMonthly: true, reportWeekly: true }); + expect(await runReportJobs(t.db, t.config, new Date(`${m3}-01T03:00:00Z`))).toBe(0); + expect(await runReportJobs(t.db, t.config, firstDay)).toBeGreaterThanOrEqual(1); + expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0); + const monthly = (await reports()).find((x) => x.startsWith('Итоги месяца'))!; + expect(monthly).toContain('К выплате:'); + expect(monthly).toContain('• Агент 1 — $600'); + expect(monthly).toContain('/team'); + // Настройки компании отметку об отправке не стирают. + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + expect(await runReportJobs(t.db, t.config, firstDay)).toBe(0); + const monday = new Date(`${m3}-01T06:00:00Z`); + monday.setUTCDate(monday.getUTCDate() + ((8 - monday.getUTCDay()) % 7 || 7)); + expect(await runReportJobs(t.db, t.config, monday)).toBe(1); + expect((await reports()).some((x) => x.startsWith('Итоги недели'))).toBe(true); + + // Свои начисления за прошлый месяц. + expect((await a1.api.get(`/api/my-pay?month=${m1}`)).body).toMatchObject({ userId: a1.id, deals: 2, payout: { amount: 1600 } }); + }); +}); diff --git a/apps/api/src/clients/routes.ts b/apps/api/src/clients/routes.ts new file mode 100644 index 0000000..1e3b690 --- /dev/null +++ b/apps/api/src/clients/routes.ts @@ -0,0 +1,427 @@ +import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared'; +import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { clientLeads, clientPages, clientPhotos, clients, companies, deals, departments, events, leads, pipelineStatuses, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts'; +import { clientViewings, matchingLeads, pageUrl } from '../showcase/service.ts'; +import { DealBody, dealInput, dealText, dealView } from '../deals/service.ts'; +import type { Guards } from '../leads/routes.ts'; +import { canSee, inDepartment, type Actor } from '../leads/service.ts'; +import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts'; +import { onlySent } from '../lib/patch.ts'; +import { + addClientComment, + assignClient, + changeClientStatus, + createClient, + deleteClient, + linkLead, + loadClient, + setClientNextAction, + unlinkLead, + updateClient, + type Client, +} from './service.ts'; + +const Id = z.object({ id: z.coerce.number().int().positive() }); +const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined); + +const ClientBody = z.object({ + name: z.string().trim().min(1).max(120), + phone: optionalText(40), + telegram: optionalText(64), + rentTerm: z.enum(RENT_TERMS).nullable().optional(), + kind: z.enum(CLIENT_KINDS), + request: ClientRequest.partial().default({}), + household: Household.nullable().optional(), + source: optionalText(120), +}); + +/** Фото к карточке клиента: до 10 штук, каждое до 5 МБ. */ +const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; +const MAX_PHOTO = 5 * 1024 * 1024; +const MAX_PHOTOS = 10; + +export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) { + type Ctx = ReturnType; + const actor = (ctx: Ctx): Actor => ctx; + + /** Телефон и Telegram клиента видны ответственному и руководителю. */ + const present = (ctx: Ctx, c: Client, assigneeName?: string | null) => { + const own = ctx.role === 'manager' || c.assigneeId === ctx.user.id; + return { + ...c, phone: own ? c.phone : null, telegram: own ? c.telegram : null, + phoneHidden: (Boolean(c.phone) || Boolean(c.telegram)) && !own, assigneeName: assigneeName ?? null, + }; + }; + + app.get('/api/clients', async (req) => { + const ctx = g.member(req); + const q = z + .object({ + tab: z.enum(['mine', 'all']).default('mine'), + statusId: z.coerce.number().int().optional(), + assigneeId: z.coerce.number().int().optional(), + kind: z.enum(CLIENT_KINDS).optional(), + q: z.string().trim().max(100).optional(), + /** Руководителю — один отдел; none — клиенты без отдела. */ + departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(), + }) + .parse(req.query); + const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll; + if (q.tab === 'all' && !seeAll) throw forbidden(); + const where: (SQL | undefined)[] = [ + eq(clients.companyId, ctx.company.id), + // Сотрудник видит своих клиентов и клиентов своего отдела. + ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)), + ]; + if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(clients.departmentId, q.departmentId === 'none' ? null : q.departmentId)); + if (q.tab === 'mine') where.push(eq(clients.assigneeId, ctx.user.id)); + if (q.statusId) where.push(eq(clients.statusId, q.statusId)); + if (q.assigneeId && q.tab === 'all') where.push(eq(clients.assigneeId, q.assigneeId)); + if (q.kind) where.push(eq(clients.kind, q.kind)); + if (q.q) { + const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`; + const digits = q.q.replace(/\D/g, ''); + where.push( + or( + ilike(clients.name, like), + // По телефону агент находит только своих клиентов — чужие номера скрыты. + digits.length >= 4 + ? and(ilike(clients.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id)) + : undefined, + ), + ); + } + const rows = await db + .select({ + client: clients, + assigneeName: users.name, + departmentName: departments.name, + leadsCount: sql`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`, + }) + .from(clients) + .leftJoin(users, eq(users.id, clients.assigneeId)) + .leftJoin(departments, eq(departments.id, clients.departmentId)) + .where(and(...where)) + .orderBy(desc(clients.createdAt)) + .limit(300); + return rows.map((r) => ({ ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount })); + }); + + app.get('/api/clients-summary', async (req) => { + const ctx = g.member(req); + const [row] = await db + .select({ + mine: sql`count(*) filter (where ${clients.assigneeId} = ${ctx.user.id})::int`, + all: sql`count(*)::int`, + }) + .from(clients) + .where( + and( + eq(clients.companyId, ctx.company.id), + ctx.role === 'manager' ? undefined : or(eq(clients.assigneeId, ctx.user.id), inDepartment(clients.departmentId, ctx.user.departmentId)), + ), + ); + return row ?? { mine: 0, all: 0 }; + }); + + /** Объекты, прикреплённые к клиенту; чужие лиды, невидимые агенту, в список не попадают. */ + const linkedLeads = async (ctx: Ctx, clientId: number) => { + const rows = await db + .select({ link: clientLeads, lead: leads, assigneeName: users.name }) + .from(clientLeads) + .innerJoin(leads, eq(leads.id, clientLeads.leadId)) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .where(eq(clientLeads.clientId, clientId)) + .orderBy(desc(clientLeads.createdAt)); + return rows + .filter((r) => canSee(actor(ctx), r.lead)) + .map(({ link, lead, assigneeName }) => ({ + id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType, + district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null, + statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName, state: link.state, shownAt: link.shownAt, + })); + }; + + app.get('/api/clients/:id', async (req) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const client = await loadClient(db, actor(ctx), id); + const photos = await db + .select({ id: clientPhotos.id, createdAt: clientPhotos.createdAt }) + .from(clientPhotos) + .where(eq(clientPhotos.clientId, id)) + .orderBy(clientPhotos.id); + const [history, assignee, linked, deal] = await Promise.all([ + db + .select({ event: events, userName: users.name }) + .from(events) + .leftJoin(users, eq(users.id, events.userId)) + .where(eq(events.clientId, id)) + .orderBy(desc(events.at), desc(events.id)), + db.select({ name: users.name }).from(users).where(eq(users.id, client.assigneeId)), + linkedLeads(ctx, id), + db + .select({ deal: deals, leadTitle: leads.title }) + .from(deals) + .leftJoin(leads, eq(leads.id, deals.leadId)) + .where(eq(deals.clientId, id)), + ]); + const names = new Map( + (await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map( + (u) => [String(u.id), u.name] as const, + ), + ); + const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null); + const d = deal[0]; + const [page] = await db + .select({ token: clientPages.token, slug: companies.slug }) + .from(clientPages) + .innerJoin(companies, eq(companies.id, clientPages.companyId)) + .where(and(eq(clientPages.clientId, id), isNull(clientPages.revokedAt))); + return { + ...present(ctx, client, assignee[0]?.name), + photos, + /** Комиссия агентства с клиента: условие и ожидаемая сумма от его бюджета. */ + commission: commissionView( + ctx.company.settings.commission, client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer', client.commission, + client.request.priceTo, client.request.priceCurrency ?? 'USD', + ), + /** Страница подборки для клиента, если ссылка создана. */ + pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null, + viewings: await clientViewings(db, id), + leads: linked, + deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null, + events: history.map(({ event: e, userName }) => ({ + id: e.id, type: e.type, at: e.at, userName, + oldValue: e.type === 'assigned' ? byId(e.oldValue) : e.oldValue, + newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue, + text: e.text, + })), + }; + }); + + /** + * Подбор: объекты компании, подходящие под запрос клиента и ещё не прикреплённые к нему. + * Закрытые сделкой и отказом не предлагаются. + */ + app.get('/api/clients/:id/suggestions', async (req) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const client = await loadClient(db, actor(ctx), id); + return (await matchingLeads(db, actor(ctx), client)) + .slice(0, 30) + .map(({ lead, assigneeName }) => ({ + id: lead.id, title: lead.title, price: lead.price, currency: lead.currency, dealType: lead.dealType, + district: lead.district, region: lead.region, address: lead.address, photo: lead.photos[0] ?? null, + statusId: lead.statusId, assigneeId: lead.assigneeId, assigneeName, + })); + }); + + /** Условие комиссии для этого клиента; null возвращает условие из настроек компании. */ + app.post('/api/clients/:id/commission', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body); + const client = await loadClient(db, actor(ctx), id); + if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT'); + const before = ruleCode(client.commission); + const after = ruleCode(rule); + if (before === after) return { ok: true }; + await db.transaction(async (tx) => { + await tx.update(clients).set({ commission: rule }).where(eq(clients.id, id)); + const settings = ctx.company.settings.commission?.[client.kind === 'tenant' ? 'clientTenant' : 'clientBuyer']; + await tx.insert(events).values({ + companyId: ctx.company.id, clientId: id, userId: ctx.user.id, type: 'commission', + oldValue: before ?? ruleCode(settings), newValue: after, + text: commissionNote( + after ? null : ruleCode(settings), + commissionDelta(client.commission ?? settings, rule ?? settings, client.request.priceTo, client.request.priceCurrency ?? 'USD'), + ), + }); + }); + return { ok: true }; + }); + + // --- фото к карточке клиента --- + + const ownClient = async (ctx: Ctx, id: number) => { + const client = await loadClient(db, actor(ctx), id); + if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT'); + return client; + }; + + /** Файл приходит телом запроса как есть (application/octet-stream), тип картинки — в адресе. */ + app.post('/api/clients/:id/photos', { bodyLimit: MAX_PHOTO + 1024 }, async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { type } = z.object({ type: z.string() }).parse(req.query); + if (!PHOTO_TYPES.includes(type)) throw badRequest('PHOTO_TYPE'); + if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION'); + if (req.body.length > MAX_PHOTO) throw badRequest('FILE_TOO_LARGE'); + await ownClient(ctx, id); + const [{ n } = { n: 0 }] = await db.select({ n: sql`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, id)); + if (n >= MAX_PHOTOS) throw conflict('PHOTO_LIMIT'); + const [photo] = await db + .insert(clientPhotos) + .values({ clientId: id, companyId: ctx.company.id, contentType: type, size: req.body.length, data: req.body.toString('base64'), uploadedById: ctx.user.id }) + .returning({ id: clientPhotos.id }); + return { id: photo!.id }; + }); + + app.get('/api/clients/:id/photos/:photoId', async (req, reply) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params); + await loadClient(db, actor(ctx), id); + const [photo] = await db.select().from(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id))); + if (!photo) throw notFound(); + return reply + .header('content-type', photo.contentType) + .header('cache-control', 'private, max-age=86400') + .send(Buffer.from(photo.data, 'base64')); + }); + + app.delete('/api/clients/:id/photos/:photoId', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { photoId } = z.object({ photoId: z.coerce.number().int().positive() }).parse(req.params); + await ownClient(ctx, id); + await db.delete(clientPhotos).where(and(eq(clientPhotos.id, photoId), eq(clientPhotos.clientId, id))); + return { ok: true }; + }); + + app.post('/api/clients', async (req) => { + const ctx = g.writable(g.member(req)); + // Кто создал клиента, тот и ответственный; передать другому можно в карточке. + const client = await createClient(db, actor(ctx), ClientBody.parse(req.body)); + return { id: client.id }; + }); + + app.patch('/api/clients/:id', async (req) => { + const ctx = g.writable(g.member(req)); + // Пустые телефон, Telegram, срок и источник стираются, а не пропускаются. + const body = onlySent( + req.body, + ClientBody.partial() + .extend({ + phone: z.string().trim().max(40).optional(), + telegram: z.string().trim().max(64).optional(), + source: z.string().trim().max(120).optional(), + }) + .parse(req.body), + ); + await updateClient(db, actor(ctx), Id.parse(req.params).id, { + ...body, + ...(body.source !== undefined ? { source: body.source || null } : {}), + }); + return { ok: true }; + }); + + /** Удалить карточку клиента: руководитель; со сделкой — только суперадмин. */ + app.delete('/api/clients/:id', async (req) => { + const ctx = g.writable(g.member(req)); + await deleteClient(db, actor(ctx), Id.parse(req.params).id); + return { ok: true }; + }); + + app.post('/api/clients/:id/status', async (req) => { + const ctx = g.writable(g.member(req)); + const body = z + .object({ + statusId: z.number().int().positive(), + rejectReasonId: z.number().int().positive().optional(), + deal: DealBody.extend({ leadId: z.number().int().positive().optional() }).optional(), + }) + .parse(req.body); + await changeClientStatus(db, actor(ctx), Id.parse(req.params).id, body); + return { ok: true }; + }); + + app.post('/api/clients/:id/assign', async (req) => { + const ctx = g.writable(g.manager(req)); + const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body); + await assignClient(db, actor(ctx), Id.parse(req.params).id, userId); + return { ok: true }; + }); + + app.post('/api/clients/:id/comments', async (req) => { + const ctx = g.writable(g.member(req)); + const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body); + await addClientComment(db, actor(ctx), Id.parse(req.params).id, text); + return { ok: true }; + }); + + app.post('/api/clients/:id/next-action', async (req) => { + const ctx = g.writable(g.member(req)); + const body = z + .object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) }) + .parse(req.body); + await setClientNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null); + return { ok: true }; + }); + + app.post('/api/clients/:id/leads', async (req) => { + const ctx = g.writable(g.member(req)); + const body = z + .object({ leadId: z.number().int().positive(), state: z.enum(['offered', 'shown']).default('offered') }) + .parse(req.body); + await linkLead(db, actor(ctx), Id.parse(req.params).id, body.leadId, body.state); + return { ok: true }; + }); + + app.delete('/api/clients/:id/leads/:leadId', async (req) => { + const ctx = g.writable(g.member(req)); + const p = Id.extend({ leadId: z.coerce.number().int().positive() }).parse(req.params); + await unlinkLead(db, actor(ctx), p.id, p.leadId); + return { ok: true }; + }); + + /** Части комиссии и дату сделки может исправить руководитель. */ + app.patch('/api/deals/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + const body = DealBody.parse(req.body); + await db.transaction(async (tx) => { + const [deal] = await tx + .select() + .from(deals) + .where(and(eq(deals.id, id), eq(deals.companyId, ctx.company.id))) + .for('update'); + if (!deal) throw notFound(); + // Сумма без разбивки остаётся на той стороне, где была. + const next = dealInput(body, deal.ownerCommission > 0 || deal.clientCommission === 0 ? 'owner' : 'client'); + await tx + .update(deals) + .set({ commission: next.commission, ownerCommission: next.ownerCommission, clientCommission: next.clientCommission, currency: next.currency, dealDate: next.date }) + .where(eq(deals.id, id)); + const base = { companyId: ctx.company.id, userId: ctx.user.id, type: 'deal_edited', text: dealText(next) }; + if (deal.leadId) await tx.insert(events).values({ ...base, leadId: deal.leadId }); + if (deal.clientId) await tx.insert(events).values({ ...base, clientId: deal.clientId }); + }); + return { ok: true }; + }); + + /** Клиенты агента, к которым можно прикрепить объект: из карточки объекта. */ + app.get('/api/clients-options', async (req) => { + const ctx = g.member(req); + const rows = await db + .select({ id: clients.id, name: clients.name, kind: clients.kind, code: pipelineStatuses.code }) + .from(clients) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, clients.statusId)) + .where( + and( + eq(clients.companyId, ctx.company.id), + ctx.role === 'manager' ? undefined : eq(clients.assigneeId, ctx.user.id), + or(isNull(pipelineStatuses.code), eq(pipelineStatuses.code, 'new')), + ), + ) + .orderBy(desc(clients.createdAt)) + .limit(300); + return rows.map(({ id, name, kind }) => ({ id, name, kind })); + }); +} diff --git a/apps/api/src/clients/service.ts b/apps/api/src/clients/service.ts new file mode 100644 index 0000000..b6abd32 --- /dev/null +++ b/apps/api/src/clients/service.ts @@ -0,0 +1,305 @@ +import { normalizePhone, type ClientKind, type ClientRequest, type Household, type RentTerm } from '@lr/shared'; +import { and, eq, isNull, sql } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { clientLeads, clients, companies, deals, events, rejectReasons, users } from '../db/schema.ts'; +import { dealInput, dealText, dropDealSide, moveLeadToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts'; +import { loadLead, sameDepartment, type Actor } from '../leads/service.ts'; +import { badRequest, forbidden, notFound } from '../lib/errors.ts'; +import { pipelineOf, statusByCode } from '../pipelines/service.ts'; +import { refreshStaleGallery } from '../showcase/service.ts'; +import { enqueueText } from '../telegram/outbox.ts'; + +// Воронка клиента (покупатель или арендатор). Клиента агент заводит вручную, он закреплён +// за агентом: правила пула на клиентов не действуют, переназначает только руководитель. + +export type Client = typeof clients.$inferSelect; + +const isManager = (a: Actor) => a.role === 'manager'; + +/** + * Клиента видят руководитель и ответственный; остальные агенты — при открытой видимости и только + * клиентов своего отдела. + */ +export function canSeeClient(actor: Actor, client: Client): boolean { + if (client.companyId !== actor.company.id) return false; + if (isManager(actor) || client.assigneeId === actor.user.id) return true; + return actor.company.settings.agentsSeeAll && sameDepartment(client.departmentId, actor.user.departmentId); +} + +function assertCanWork(actor: Actor, client: Client) { + if (!isManager(actor) && client.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_CLIENT'); +} + +export async function loadClient(tx: Tx, actor: Actor, clientId: number): Promise { + const [client] = await tx + .select() + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.companyId, actor.company.id))); + if (!client || !canSeeClient(actor, client)) throw notFound('CLIENT_NOT_FOUND'); + return client; +} + +async function logEvent( + tx: Tx, + client: Pick, + userId: number | null, + type: string, + extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {}, +) { + await tx.insert(events).values({ companyId: client.companyId, clientId: client.id, userId, type, ...extra }); +} + +const touchCompany = (tx: Tx, companyId: number) => + tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId)); + +async function member(tx: Tx, companyId: number, userId: number) { + const [user] = await tx + .select() + .from(users) + .where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt))); + if (!user) throw badRequest('BAD_ASSIGNEE'); + return user; +} + +/** Telegram клиента в одном виде: @ник; ссылка t.me и ник без @ приводятся к нему. */ +export function normalizeTelegram(raw: string | null | undefined): string | null { + const v = raw?.trim(); + if (!v) return null; + const nick = /^(?:https?:\/\/)?(?:t\.me|telegram\.me)\/([A-Za-z0-9_]{4,32})\/?$/.exec(v)?.[1] ?? /^@?([A-Za-z0-9_]{4,32})$/.exec(v)?.[1]; + return nick ? `@${nick}` : v; +} + +export interface ClientInput { + name: string; + phone?: string; + telegram?: string; + rentTerm?: RentTerm | null; + kind: ClientKind; + request: Partial; + /** Кто будет жить; null — сведения убраны. */ + household?: Household | null; + source?: string; +} + +export async function createClient(db: Db, actor: Actor, input: ClientInput): Promise { + return db.transaction(async (tx) => { + const fresh = await statusByCode(tx, actor.company.id, 'new', 'client'); + const [client] = await tx + .insert(clients) + .values({ + ...input, + phone: normalizePhone(input.phone) ?? input.phone ?? null, + telegram: normalizeTelegram(input.telegram), + companyId: actor.company.id, + assigneeId: actor.user.id, + // Клиент попадает в отдел того, кто его завёл. + departmentId: actor.user.departmentId, + statusId: fresh.id, + }) + .returning(); + await logEvent(tx, client!, actor.user.id, 'created'); + await touchCompany(tx, actor.company.id); + return client!; + }); +} + +export async function updateClient( + db: Db, + actor: Actor, + clientId: number, + patch: Partial> & { source?: string | null }, +) { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + await tx + .update(clients) + .set({ + ...patch, + ...(patch.phone !== undefined ? { phone: normalizePhone(patch.phone) ?? (patch.phone || null) } : {}), + ...(patch.telegram !== undefined ? { telegram: normalizeTelegram(patch.telegram) } : {}), + }) + .where(eq(clients.id, clientId)); + await logEvent(tx, client, actor.user.id, 'edited'); + await touchCompany(tx, actor.company.id); + }); +} + +/** Удаление карточки клиента вместе с историей и фото: как у объекта — руководитель, со сделкой — только суперадмин. */ +export async function deleteClient(db: Db, actor: Actor, clientId: number) { + if (!isManager(actor)) throw forbidden(); + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.clientId, client.id)); + if (deal) { + if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL'); + await dropDealSide(tx, 'client', client.id); + } + await tx.delete(events).where(eq(events.clientId, client.id)); + await tx.delete(clients).where(eq(clients.id, client.id)); + await touchCompany(tx, actor.company.id); + }); +} + +export interface ClientStatusChange { + statusId: number; + rejectReasonId?: number; + /** Обязательна при переводе в «Сделка». leadId — объект из CRM, с которым прошла сделка. */ + deal?: DealBody & { leadId?: number }; +} + +export async function changeClientStatus(db: Db, actor: Actor, clientId: number, change: ClientStatusChange) { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + const statuses = await pipelineOf(tx, actor.company.id, 'client'); + const from = statuses.find((s) => s.id === client.statusId); + const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt); + if (!to) throw badRequest('BAD_STATUS'); + if (to.id === client.statusId) return; + if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED'); + + let rejectReasonId: number | null = null; + if (to.code === 'rejected') { + const [reason] = change.rejectReasonId + ? await tx + .select() + .from(rejectReasons) + .where( + and( + eq(rejectReasons.id, change.rejectReasonId), + eq(rejectReasons.companyId, actor.company.id), + eq(rejectReasons.pipeline, 'client'), + ), + ) + : []; + if (!reason) throw badRequest('REJECT_REASON_REQUIRED'); + rejectReasonId = reason.id; + } + let deal: DealInput | null = null; + if (to.code === 'deal') { + if (!change.deal) throw badRequest('COMMISSION_REQUIRED'); + deal = dealInput(change.deal, 'client'); + if (change.deal.leadId) { + // Объект пары должен быть виден агенту; в «Сделка» он переходит вместе с клиентом. + await loadLead(tx, actor, change.deal.leadId); + await moveLeadToDeal(tx, actor.company.id, actor.user.id, clientId, change.deal.leadId, deal); + } + await recordDeal(tx, { + companyId: actor.company.id, + leadId: change.deal.leadId ?? null, + clientId, + agentId: client.assigneeId, + deal, + }); + } else if (from?.code === 'deal') { + await dropDealSide(tx, 'client', clientId); + } + + await tx + .update(clients) + .set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date() }) + .where(eq(clients.id, clientId)); + await logEvent(tx, client, actor.user.id, to.code === 'deal' ? 'deal' : 'status', { + oldValue: from?.name ?? null, + newValue: to.name, + statusId: to.id, + text: deal ? dealText(deal) : null, + }); + await touchCompany(tx, actor.company.id); + }); +} + +/** Переназначить клиента может только руководитель. Оба агента получают уведомление. */ +export async function assignClient(db: Db, actor: Actor, clientId: number, toUserId: number) { + if (!isManager(actor)) throw forbidden(); + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + if (client.assigneeId === toUserId) return; + const target = await member(tx, actor.company.id, toUserId); + // Клиент переходит в отдел нового ответственного. + await tx.update(clients).set({ assigneeId: toUserId, departmentId: target.departmentId }).where(eq(clients.id, clientId)); + await logEvent(tx, client, actor.user.id, 'assigned', { + oldValue: String(client.assigneeId), + newValue: String(toUserId), + }); + if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'clientAssigned', [client.name]); + if (client.assigneeId !== actor.user.id) { + await enqueueText(tx, [client.assigneeId], 'clientReassignedAway', [client.name, target.name]); + } + await touchCompany(tx, actor.company.id); + }); +} + +export async function addClientComment(db: Db, actor: Actor, clientId: number, text: string) { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + await logEvent(tx, client, actor.user.id, 'comment', { text }); + await touchCompany(tx, actor.company.id); + }); +} + +export async function setClientNextAction(db: Db, actor: Actor, clientId: number, at: Date | null, note: string | null) { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + await tx + .update(clients) + .set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null }) + .where(eq(clients.id, clientId)); + }); +} + +/** + * Прикрепить объект к клиенту: «предложен» или «показан». Повторный вызов меняет отметку. + * Событие пишется и в историю клиента, и в историю объекта. + */ +export async function linkLead(db: Db, actor: Actor, clientId: number, leadId: number, state: 'offered' | 'shown') { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + const lead = await loadLead(tx, actor, leadId); + const [before] = await tx + .select() + .from(clientLeads) + .where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId))); + if (before?.state === state) return; + const shownAt = state === 'shown' ? new Date() : null; + await tx + .insert(clientLeads) + .values({ clientId, leadId, state, shownAt }) + .onConflictDoUpdate({ target: [clientLeads.clientId, clientLeads.leadId], set: { state, shownAt } }); + if (!before) await refreshStaleGallery(tx, [leadId]); + await logEvent(tx, client, actor.user.id, state, { newValue: lead.title }); + await tx.insert(events).values({ + companyId: actor.company.id, leadId, userId: actor.user.id, type: `client_${state}`, newValue: client.name, + }); + await touchCompany(tx, actor.company.id); + }); +} + +export async function unlinkLead(db: Db, actor: Actor, clientId: number, leadId: number) { + await db.transaction(async (tx) => { + const client = await loadClient(tx, actor, clientId); + assertCanWork(actor, client); + const [gone] = await tx + .delete(clientLeads) + .where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId))) + .returning(); + if (!gone) return; + const lead = await loadLead(tx, actor, leadId).catch(() => null); + await logEvent(tx, client, actor.user.id, 'unlinked', { newValue: lead?.title ?? null }); + }); +} + +/** Напоминания о следующем действии по клиентам. Запускается раз в минуту вместе с задачами по лидам. */ +export async function runClientJobs(db: Db, now = new Date()): Promise<{ reminded: number }> { + const due = await db + .update(clients) + .set({ nextActionNotifiedAt: now }) + .where(and(sql`${clients.nextActionAt} <= ${now.toISOString()}::timestamptz`, isNull(clients.nextActionNotifiedAt))) + .returning(); + for (const c of due) await enqueueText(db, [c.assigneeId], 'clientReminder', [c.name, c.nextActionNote]); + return { reminded: due.length }; +} diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts new file mode 100644 index 0000000..8cdc112 --- /dev/null +++ b/apps/api/src/companies/companies.service.ts @@ -0,0 +1,147 @@ +import { + allowedBillingModels, + DEFAULT_GRACE_DAYS, + DEFAULT_REJECT_REASONS, + DEFAULT_STATUSES, + PIPELINES, + PLAN_SPECS, + POOL_RETURN_DEFAULT_HOURS, + type BillingModel, + type Lang, + type Plan, +} from '@lr/shared'; +import { and, count, eq, isNull, sql } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { companies, invites, pipelineStatuses, rejectReasons, users } from '../db/schema.ts'; +import { newToken } from '../auth/sessions.ts'; +import { badRequest, conflict, notFound } from '../lib/errors.ts'; +import { freeSlug } from '../showcase/service.ts'; + +export interface NewCompany { + name: string; + plan: Plan; + billingModel: BillingModel; + /** Язык, на котором создаются статусы и причины отказа по умолчанию. */ + lang: Lang; + /** Суперадмин включает доступ сразу; при самостоятельной регистрации — после оплаты. */ + accessEnabled?: boolean; + subscriptionEndsAt?: Date | null; +} + +/** Создаёт компанию с воронками и причинами отказа по умолчанию. */ +export async function createCompany(tx: Tx, input: NewCompany) { + if (!allowedBillingModels(input.plan).includes(input.billingModel)) { + throw badRequest('BILLING_MODEL_NOT_ALLOWED'); + } + const [company] = await tx + .insert(companies) + .values({ + name: input.name, + plan: input.plan, + billingModel: input.billingModel, + graceDays: DEFAULT_GRACE_DAYS, + // Компания, включённая суперадмином, сразу на модели «%»; при самостоятельном подключении — с оплаты. + percentFrom: + input.billingModel === 'percent' && input.subscriptionEndsAt + ? new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10) + : null, + accessEnabled: input.accessEnabled ?? false, + subscriptionEndsAt: input.subscriptionEndsAt ?? null, + settings: { + agentsSeeAll: false, + poolReturnHours: POOL_RETURN_DEFAULT_HOURS, + agentRules: { useAgencyBadge: true, phoneThreshold: 3 }, + }, + }) + .returning(); + // Адрес страниц для клиентов — из названия компании; руководитель может поменять его в настройках. + company!.slug = await freeSlug(tx, company!.name, company!.id); + await tx.update(companies).set({ slug: company!.slug }).where(eq(companies.id, company!.id)); + for (const pipeline of PIPELINES) { + await tx.insert(pipelineStatuses).values( + DEFAULT_STATUSES[pipeline].map((s, i) => ({ + companyId: company!.id, + pipeline, + code: s.code ?? null, + name: s.name[input.lang], + position: i, + poolReturn: s.poolReturn ?? false, + })), + ); + await tx.insert(rejectReasons).values( + DEFAULT_REJECT_REASONS[pipeline].map((r, i) => ({ + companyId: company!.id, + pipeline, + code: r.code ?? null, + name: r[input.lang], + position: i, + })), + ); + } + return company!; +} + +const activeMembers = (companyId: number) => + and(eq(users.companyId, companyId), isNull(users.removedAt)); + +export async function memberCount(tx: Tx, companyId: number): Promise { + const [row] = await tx.select({ n: count() }).from(users).where(activeMembers(companyId)); + return row?.n ?? 0; +} + +export async function createInvite(db: Db, companyId: number, createdById: number) { + const [invite] = await db + .insert(invites) + .values({ token: newToken(), companyId, createdById }) + .returning(); + return invite!; +} + +export interface JoinResult { + companyId: number; + /** После этого вступления компания упёрлась в лимит тарифа. */ + limitReached: boolean; +} + +/** + * Принимает приглашение: пользователь становится агентом компании. + * Лимит участников проверяется под блокировкой строки компании, чтобы два одновременных + * вступления не превысили тариф. + */ +export async function acceptInvite(db: Db, token: string, userId: number): Promise { + return db.transaction(async (tx) => { + const [invite] = await tx.select().from(invites).where(eq(invites.token, token)); + if (!invite || invite.revokedAt) throw notFound('INVITE_NOT_FOUND'); + const [company] = await tx + .select() + .from(companies) + .where(eq(companies.id, invite.companyId)) + .for('update'); + if (!company) throw notFound('INVITE_NOT_FOUND'); + + const [user] = await tx.select().from(users).where(eq(users.id, userId)); + if (!user) throw notFound(); + if (user.companyId === company.id && !user.removedAt) { + return { companyId: company.id, limitReached: false }; + } + if (user.companyId && !user.removedAt) throw conflict('ALREADY_IN_COMPANY'); + if (user.role === 'superadmin') throw conflict('ALREADY_IN_COMPANY'); + + const max = PLAN_SPECS[company.plan as Plan].maxMembers; + const current = await memberCount(tx, company.id); + if (current >= max) throw conflict('MEMBER_LIMIT_REACHED'); + + await tx + .update(users) + .set({ companyId: company.id, role: invite.role, removedAt: null }) + .where(eq(users.id, userId)); + if (invite.role === 'manager') { + await tx.update(invites).set({ revokedAt: new Date() }).where(eq(invites.token, token)); + } + await tx + .update(companies) + .set({ lastActivityAt: sql`now()` }) + .where(eq(companies.id, company.id)); + return { companyId: company.id, limitReached: current + 1 >= max }; + }); +} diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts new file mode 100644 index 0000000..6c84349 --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,12 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema.ts'; + +export function connect(url: string) { + const sql = postgres(url, { onnotice: () => {} }); + return { db: drizzle(sql, { schema }), close: () => sql.end() }; +} + +export type Db = ReturnType['db']; +/** Транзакция или обычное подключение — сервисы принимают оба. */ +export type Tx = Parameters[0]>[0] | Db; diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..e9600c0 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { connect } from './client.ts'; + +export const MIGRATIONS_DIR = fileURLToPath(new URL('../../drizzle', import.meta.url)); + +if (import.meta.filename === process.argv[1]) { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL не задан'); + const { db, close } = connect(url); + await migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + await close(); + console.log('Миграции применены'); +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..b61f1fc --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,785 @@ +import type { AgentPay, AgentRules, CommissionRule, CommissionSettings, FilterParams, Household, ListingFeatures } from '@lr/shared'; +import { sql } from 'drizzle-orm'; +import { + bigint, + boolean, + date, + index, + integer, + jsonb, + numeric, + pgTable, + primaryKey, + serial, + text, + timestamp, + uniqueIndex, +} from 'drizzle-orm/pg-core'; + +// Схема данных (ТЗ, раздел 9). Данные компаний изолированы: у каждой бизнес-таблицы есть +// company_id, и каждый запрос API фильтруется по компании пользователя. + +const ts = (name: string) => timestamp(name, { withTimezone: true }); +const createdAt = ts('created_at').notNull().defaultNow(); +const money = (name: string) => numeric(name, { precision: 14, scale: 2, mode: 'number' }); + +export interface CompanySettings { + /** «Агенты видят все лиды компании». */ + agentsSeeAll: boolean; + /** Объявление сняли с List.am — открытые лиды по нему сами уходят в «Отказ» («Уже продано или сдано»). */ + autoRejectRemoved?: boolean; + /** Через сколько часов без смены статуса взятый лид возвращается в пул. */ + poolReturnHours: number; + agentRules: AgentRules; + /** Комиссия агентства по умолчанию: для объектов и клиентов, продажи и аренды. */ + commission?: CommissionSettings; + /** Отчёты руководителю в Telegram: за неделю и за месяц. sent* — за какой период отчёт уже ушёл. */ + reports?: { weekly: boolean; monthly: boolean; sentWeekly?: string; sentMonthly?: string }; + /** Свой домен для ссылок клиентам (например, arev.yandp.tech); пусто — адрес сервиса. Задаёт суперадмин. */ + clientHost?: string; + /** + * Бот компании, который присылает в её группу объекты, взятые в работу с указанной комиссией. + * Задаёт суперадмин; токен наружу не отдаётся. + */ + groupBot?: { token: string; chatId: number; /** Топик группы («Свободные объекты»); пусто — общий чат. */ threadId?: number }; + /** + * Витрина на сайте BarevClub: по ключу сайт берёт объекты компании и присылает заявки на показ. + * threadId — топик группы, куда бот группы пишет о новой заявке. Задаёт суперадмин; ключ наружу не отдаётся. + */ + barevClub?: { key: string; threadId?: number }; +} + +export const companies = pgTable('companies', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + /** Короткое имя компании в адресе страниц для клиентов: listcrm…//<код>. */ + slug: text('slug').unique(), + /** solo | team | agency */ + plan: text('plan').notNull(), + /** fixed | percent */ + billingModel: text('billing_model').notNull().default('fixed'), + /** Запланированная смена тарифа и модели: вступает в силу с plannedFrom. */ + plannedPlan: text('planned_plan'), + plannedBillingModel: text('planned_billing_model'), + plannedFrom: date('planned_from'), + subscriptionEndsAt: ts('subscription_ends_at'), + graceDays: integer('grace_days').notNull().default(15), + /** Ручной выключатель суперадмина. */ + accessEnabled: boolean('access_enabled').notNull().default(false), + setupFeeInvoiced: boolean('setup_fee_invoiced').notNull().default(false), + /** С какой даты компания на модели «%»: сделки до неё в счёт по комиссии не попадают. */ + percentFrom: date('percent_from'), + /** Ключ последнего напоминания о подписке — чтобы не слать одно и то же дважды. */ + billingNotice: text('billing_notice'), + settings: jsonb('settings').$type().notNull(), + lastActivityAt: ts('last_activity_at'), + createdAt, +}); + +/** + * Отдел компании: свои сотрудники, фильтры, объекты и клиенты. Сотрудник отдела видит только данные + * своего отдела; руководитель и суперадмин — все отделы. Без отделов компания работает как раньше: + * у всех данных отдел пустой. + */ +export const departments = pgTable( + 'departments', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + name: text('name').notNull(), + createdAt, + }, + (t) => [index('departments_company_idx').on(t.companyId)], +); + +/** Отдел записи: null — «без отдела» (общие данные компании). */ +const departmentId = () => integer('department_id').references(() => departments.id, { onDelete: 'set null' }); + +export const users = pgTable( + 'users', + { + id: serial('id').primaryKey(), + /** null — суперадмин либо пользователь, ещё не создавший компанию. */ + companyId: integer('company_id').references(() => companies.id), + /** superadmin | manager | agent */ + role: text('role').notNull(), + name: text('name').notNull(), + telegramId: bigint('telegram_id', { mode: 'number' }), + telegramUsername: text('telegram_username'), + email: text('email'), + emailVerifiedAt: ts('email_verified_at'), + passwordHash: text('password_hash'), + lang: text('lang').notNull().default('ru'), + /** Тихие часы по времени Еревана, «HH:MM». */ + quietFrom: text('quiet_from'), + quietTo: text('quiet_to'), + notificationsPaused: boolean('notifications_paused').notNull().default(false), + /** Одноразовый код привязки Telegram: t.me/<бот>?start=<код>. */ + telegramLinkToken: text('telegram_link_token'), + /** Условия оплаты агента: оклад, процент с его сделок или оклад + процент; план на месяц и бонус за него. Задаёт руководитель. */ + pay: jsonb('pay').$type(), + /** Мастер первой настройки пройден или пропущен. */ + onboardedAt: ts('onboarded_at'), + /** Удалённый из компании агент: вход закрыт, история сохраняется. */ + removedAt: ts('removed_at'), + /** Отдел сотрудника; null — без отдела. Назначает руководитель. */ + departmentId: departmentId(), + createdAt, + }, + (t) => [ + uniqueIndex('users_telegram_uq').on(t.telegramId), + uniqueIndex('users_email_uq').on(sql`lower(${t.email})`), + index('users_company_idx').on(t.companyId), + ], +); + +export const sessions = pgTable('sessions', { + /** SHA-256 от токена из cookie. */ + tokenHash: text('token_hash').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + /** Суперадмин вошёл в кабинет компании для поддержки. */ + actingCompanyId: integer('acting_company_id').references(() => companies.id), + /** Сессия открыта из Telegram Mini App: тарифу без веб-кабинета доступна только она. */ + viaTelegram: boolean('via_telegram').notNull().default(false), + expiresAt: ts('expires_at').notNull(), + createdAt, +}); + +/** Язык, который человек выбрал в боте до регистрации: на нём бот отвечает и создаётся его аккаунт. */ +export const botLangs = pgTable('bot_langs', { + telegramId: bigint('telegram_id', { mode: 'number' }).primaryKey(), + lang: text('lang').notNull(), + createdAt, +}); + +/** Одноразовые ссылки: подтверждение email и восстановление пароля. */ +export const emailTokens = pgTable('email_tokens', { + tokenHash: text('token_hash').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + /** verify | reset */ + kind: text('kind').notNull(), + expiresAt: ts('expires_at').notNull(), +}); + +export const invites = pgTable('invites', { + token: text('token').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + /** agent — многоразовая ссылка для агентов; manager — одноразовая ссылка руководителю от суперадмина. */ + role: text('role').notNull().default('agent'), + createdById: integer('created_by_id').notNull().references(() => users.id), + revokedAt: ts('revoked_at'), + createdAt, +}); + +/** Поля объекта недвижимости — общие для объявления из парсера и для лида. */ +const propertyColumns = { + title: text('title').notNull(), + description: text('description'), + /** sale | rent */ + dealType: text('deal_type'), + propertyType: text('property_type'), + region: text('region'), + district: text('district'), + address: text('address'), + price: money('price'), + currency: text('currency'), + rooms: integer('rooms'), + area: numeric('area', { precision: 10, scale: 2, mode: 'number' }), + floor: integer('floor'), + floorsTotal: integer('floors_total'), + photos: jsonb('photos').$type().notNull().default([]), + authorName: text('author_name'), + /** Нормализованный телефон автора (+374…). */ + phone: text('phone'), + /** Удобства и правила проживания со страницы объявления. */ + features: jsonb('features').$type(), +}; + +/** + * Общая база объявлений сервиса: всё свежее с list.am (не старше 3 дней на момент находки). + * Объявление сохраняется один раз и уже отсюда расходится клиентам по их фильтрам. + */ +export const listings = pgTable( + 'listings', + { + id: serial('id').primaryKey(), + externalId: text('external_id').notNull(), + url: text('url').notNull(), + ...propertyColumns, + isAgency: boolean('is_agency').notNull().default(false), + publishedAt: ts('published_at'), + /** Когда объявление последний раз «подняли» на list.am: по этому времени объект встаёт наверх. */ + bumpedAt: ts('bumped_at'), + active: boolean('active').notNull().default(true), + removedAt: ts('removed_at'), + lastCheckedAt: ts('last_checked_at'), + createdAt, + }, + (t) => [ + uniqueIndex('listings_external_uq').on(t.externalId), + index('listings_phone_idx').on(t.phone), + ], +); + +export const filters = pgTable( + 'filters', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + /** null — общий фильтр компании или отдела; иначе личный фильтр агента. */ + ownerUserId: integer('owner_user_id').references(() => users.id), + /** Фильтр отдела: объекты по нему приходят в отдел. У личного фильтра пусто — отдел берётся у владельца. */ + departmentId: departmentId(), + name: text('name').notNull(), + enabled: boolean('enabled').notNull().default(true), + params: jsonb('params').$type().notNull(), + createdAt, + }, + (t) => [index('filters_company_idx').on(t.companyId)], +); + +export const pipelineStatuses = pgTable( + 'pipeline_statuses', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + /** object | client */ + pipeline: text('pipeline').notNull(), + /** Код системного статуса (new, taken, deal, rejected); у пользовательских — null. */ + code: text('code'), + name: text('name').notNull(), + position: integer('position').notNull(), + /** Взятый лид в этом статусе возвращается в пул по правилу «N часов без смены статуса». */ + poolReturn: boolean('pool_return').notNull().default(false), + /** Удалённый статус скрыт из воронки, но остаётся в истории. */ + deletedAt: ts('deleted_at'), + }, + (t) => [index('statuses_company_idx').on(t.companyId, t.pipeline)], +); + +export const rejectReasons = pgTable('reject_reasons', { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + pipeline: text('pipeline').notNull(), + /** agent — системная причина кнопки «Это агент»; у остальных null. */ + code: text('code'), + name: text('name').notNull(), + position: integer('position').notNull(), + deletedAt: ts('deleted_at'), +}); + +/** Лид — объект в воронке компании. Одно объявление может породить лиды в разных компаниях. */ +export const leads = pgTable( + 'leads', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + /** null — лид создан вручную. */ + listingId: integer('listing_id').references(() => listings.id), + /** listam | manual */ + source: text('source').notNull(), + url: text('url'), + ...propertyColumns, + /** null — лид в пуле. */ + assigneeId: integer('assignee_id').references(() => users.id), + statusId: integer('status_id').notNull().references(() => pipelineStatuses.id), + rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id), + nextActionAt: ts('next_action_at'), + nextActionNote: text('next_action_note'), + nextActionNotifiedAt: ts('next_action_notified_at'), + takenAt: ts('taken_at'), + statusChangedAt: ts('status_changed_at').notNull().defaultNow(), + /** Когда отправлено предупреждение «лид вернётся в пул». */ + poolWarnedAt: ts('pool_warned_at'), + /** Условие комиссии именно для этого объекта; null — как в настройках компании. */ + commission: jsonb('commission').$type(), + /** Текст объекта для страницы клиента: по шаблону или переписанный моделью. */ + showcaseText: text('showcase_text'), + /** Когда для страницы клиента загружен полный набор фото; null — ещё не загружали. */ + galleryAt: ts('gallery_at'), + galleryTries: integer('gallery_tries').notNull().default(0), + /** Карточку объекта открывали — нужно догрузить все фото и собрать текст. */ + galleryWantedAt: ts('gallery_wanted_at'), + /** Прошлый лид по тому же объекту — блок «Уже был в работе». */ + previousLeadId: integer('previous_lead_id'), + /** Объявление подняли на list.am: лид встаёт наверх списка. */ + bumpedAt: ts('bumped_at'), + /** С какой комиссией объект последний раз ушёл в группу компании; новая комиссия — новая отправка. */ + groupPostedCommission: jsonb('group_posted_commission').$type(), + groupPostedAt: ts('group_posted_at'), + createdById: integer('created_by_id').references(() => users.id), + /** Отдел, которому принадлежит объект; null — без отдела. */ + departmentId: departmentId(), + createdAt, + }, + (t) => [ + index('leads_company_status_idx').on(t.companyId, t.statusId), + index('leads_company_assignee_idx').on(t.companyId, t.assigneeId), + // Одно объявление — один лид на отдел компании: у разных отделов свои объекты. + uniqueIndex('leads_company_dept_listing_uq') + .on(t.companyId, sql`coalesce(${t.departmentId}, 0)`, t.listingId) + .where(sql`${t.previousLeadId} is null and ${t.listingId} is not null`), + ], +); + +/** Клиент — покупатель или арендатор, которого агент заводит вручную. */ +export const clients = pgTable( + 'clients', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + name: text('name').notNull(), + phone: text('phone'), + /** Ник или ссылка в Telegram: связь с клиентом, если телефона нет. */ + telegram: text('telegram'), + /** Срок аренды, который нужен арендатору: short | mid | long (RENT_TERMS). */ + rentTerm: text('rent_term'), + /** buyer | tenant */ + kind: text('kind').notNull(), + /** Запрос клиента: тип объекта, район, бюджет, комнаты, площадь. */ + request: jsonb('request').$type>().notNull().default({}), + source: text('source'), + /** Кто будет жить: состав, дети, животные. */ + household: jsonb('household').$type(), + /** Условие комиссии именно для этого клиента; null — как в настройках компании. */ + commission: jsonb('commission').$type(), + assigneeId: integer('assignee_id').notNull().references(() => users.id), + statusId: integer('status_id').notNull().references(() => pipelineStatuses.id), + rejectReasonId: integer('reject_reason_id').references(() => rejectReasons.id), + nextActionAt: ts('next_action_at'), + nextActionNote: text('next_action_note'), + nextActionNotifiedAt: ts('next_action_notified_at'), + statusChangedAt: ts('status_changed_at').notNull().defaultNow(), + /** Отдел, которому принадлежит клиент; null — без отдела. */ + departmentId: departmentId(), + createdAt, + }, + (t) => [index('clients_company_idx').on(t.companyId, t.statusId)], +); + +/** Избранное: объекты, которые пользователь отметил звёздочкой. У каждого своё. */ +export const leadFavorites = pgTable( + 'lead_favorites', + { + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }), + createdAt, + }, + (t) => [primaryKey({ columns: [t.userId, t.leadId] })], +); + +/** Пользователь открывал карточку объекта: по этому работает фильтр «Не просмотренные». */ +export const leadViews = pgTable( + 'lead_views', + { + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }), + viewedAt: ts('viewed_at').notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.userId, t.leadId] })], +); + +/** Фото к карточке клиента: жильцы, животные, документы. Хранятся в базе и попадают в её резервную копию. */ +export const clientPhotos = pgTable( + 'client_photos', + { + id: serial('id').primaryKey(), + clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }), + companyId: integer('company_id').notNull().references(() => companies.id), + contentType: text('content_type').notNull(), + size: integer('size').notNull(), + /** Содержимое файла в base64. */ + data: text('data').notNull(), + uploadedById: integer('uploaded_by_id').references(() => users.id), + createdAt, + }, + (t) => [index('client_photos_client_idx').on(t.clientId)], +); + +/** Объекты, предложенные или показанные клиенту. */ +export const clientLeads = pgTable( + 'client_leads', + { + clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }), + leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }), + /** offered | shown */ + state: text('state').notNull().default('offered'), + shownAt: ts('shown_at'), + createdAt, + }, + (t) => [uniqueIndex('client_leads_uq').on(t.clientId, t.leadId)], +); + +/** + * Сделка. Одна запись на сделку, даже если в ней и объект, и клиент из CRM — + * так комиссия не задваивается в аналитике и биллинге. + */ +export const deals = pgTable( + 'deals', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + leadId: integer('lead_id').references(() => leads.id), + clientId: integer('client_id').references(() => clients.id), + /** Кто закрыл сделку. Комиссию между агентами делят ownerAgentId и clientAgentId. */ + agentId: integer('agent_id').notNull().references(() => users.id), + /** Вся комиссия агентства: ownerCommission + clientCommission. По ней считается счёт по тарифу. */ + commission: money('commission').notNull(), + /** Часть с собственника — идёт агенту объекта. */ + ownerCommission: money('owner_commission').notNull().default(0), + /** Часть с покупателя или арендатора — идёт агенту клиента. */ + clientCommission: money('client_commission').notNull().default(0), + /** Агент объекта и агент клиента на момент сделки; если стороны в CRM нет — агент второй стороны. */ + ownerAgentId: integer('owner_agent_id').notNull().references(() => users.id), + clientAgentId: integer('client_agent_id').notNull().references(() => users.id), + /** sale | rent — от вида сделки зависит процент агента. */ + kind: text('kind').notNull().default('sale'), + currency: text('currency').notNull(), + dealDate: date('deal_date').notNull(), + createdAt, + }, + (t) => [ + index('deals_company_date_idx').on(t.companyId, t.dealDate), + uniqueIndex('deals_lead_uq').on(t.leadId), + uniqueIndex('deals_client_uq').on(t.clientId), + ], +); + +/** + * Ручное начисление или удержание агенту за месяц: премия, штраф, аванс. Сумма со знаком, + * в валюте условий агента на момент записи. Входит в итог к выплате за этот месяц. + */ +export const payAdjustments = pgTable( + 'pay_adjustments', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + userId: integer('user_id').notNull().references(() => users.id), + /** «ГГГГ-ММ». */ + month: text('month').notNull(), + amount: money('amount').notNull(), + currency: text('currency').notNull(), + note: text('note').notNull(), + createdBy: integer('created_by').notNull().references(() => users.id), + createdAt, + }, + (t) => [index('pay_adjustments_user_month_idx').on(t.companyId, t.userId, t.month)], +); + +/** Что зафиксировано при выплате: условия и курсы, по которым считался месяц, и перерасчёты прошлых месяцев, вошедшие в него. */ +export interface PayoutSnapshot { + pay: AgentPay | null; + /** Сколько единиц валюты в одном долларе. */ + rates: Record; + /** Начислено за месяц на момент выплаты — в валюте выплаты. */ + total: number; + recalc: { month: string; amount: number }[]; +} + +/** Отметка «выплачено» за месяц. После неё месяц закрыт: изменения его сделок уходят перерасчётом в следующий открытый месяц. */ +export const payouts = pgTable( + 'payouts', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + userId: integer('user_id').notNull().references(() => users.id), + /** «ГГГГ-ММ». */ + month: text('month').notNull(), + amount: money('amount').notNull(), + currency: text('currency').notNull(), + paidAt: date('paid_at').notNull(), + note: text('note'), + snapshot: jsonb('snapshot').$type().notNull(), + createdBy: integer('created_by').notNull().references(() => users.id), + createdAt, + }, + (t) => [uniqueIndex('payouts_user_month_uq').on(t.userId, t.month), index('payouts_company_idx').on(t.companyId)], +); + +/** История лида и клиента. Только дописывается: API не даёт её менять и удалять. */ +export const events = pgTable( + 'events', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + leadId: integer('lead_id').references(() => leads.id), + clientId: integer('client_id').references(() => clients.id), + /** null — действие системы (создание из парсера, возврат в пул). */ + userId: integer('user_id').references(() => users.id), + /** created | taken | assigned | status | comment | pool_return | deal | blacklisted | edited */ + type: text('type').notNull(), + oldValue: text('old_value'), + newValue: text('new_value'), + /** Статус, в который перешла карточка (события status, deal, blacklisted) — для аналитики по этапам. */ + statusId: integer('status_id'), + text: text('text'), + at: ts('at').notNull().defaultNow(), + }, + (t) => [index('events_lead_idx').on(t.leadId), index('events_client_idx').on(t.clientId)], +); + +/** Уведомления о лиде в Telegram: нужны, чтобы обновить сообщение («Взял Имя») и считать скорость реакции. */ +export const leadNotifications = pgTable( + 'lead_notifications', + { + id: serial('id').primaryKey(), + leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }), + userId: integer('user_id').notNull().references(() => users.id), + chatId: bigint('chat_id', { mode: 'number' }), + messageId: integer('message_id'), + /** Сообщение с фото: текст у него правится как подпись. */ + hasPhoto: boolean('has_photo').notNull().default(false), + sentAt: ts('sent_at'), + createdAt, + }, + (t) => [index('lead_notifications_lead_idx').on(t.leadId)], +); + +export const blacklist = pgTable( + 'blacklist', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + phone: text('phone').notNull(), + addedById: integer('added_by_id').references(() => users.id), + createdAt, + }, + (t) => [uniqueIndex('blacklist_uq').on(t.companyId, t.phone)], +); + +export const invoices = pgTable( + 'invoices', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + /** setup | fixed | percent */ + kind: text('kind').notNull(), + periodStart: date('period_start'), + periodEnd: date('period_end'), + amount: money('amount').notNull(), + currency: text('currency').notNull().default('USD'), + /** Расшифровка по сделкам для модели «%». */ + details: jsonb('details').$type(), + /** pending | paid | canceled */ + status: text('status').notNull().default('pending'), + paidAt: ts('paid_at'), + /** online | manual | auto (нулевой счёт по модели «%») */ + paidMethod: text('paid_method'), + createdAt, + }, + (t) => [index('invoices_company_idx').on(t.companyId)], +); + +export const payments = pgTable('payments', { + id: serial('id').primaryKey(), + invoiceId: integer('invoice_id').notNull().references(() => invoices.id), + provider: text('provider').notNull(), + externalId: text('external_id'), + amount: money('amount').notNull(), + currency: text('currency').notNull(), + /** pending | succeeded | failed */ + status: text('status').notNull(), + createdAt, +}); + +/** Сообщения суперадмина пользователям в Telegram. */ +export const adminMessages = pgTable('admin_messages', { + id: serial('id').primaryKey(), + text: text('text').notNull(), + /** all | company | managers | user */ + audience: text('audience').notNull(), + companyId: integer('company_id').references(() => companies.id), + userId: integer('user_id').references(() => users.id), + recipients: integer('recipients').notNull().default(0), + /** Сколько из получателей без привязанного Telegram — им сообщение не дойдёт. */ + unreachable: integer('unreachable').notNull().default(0), + sentAt: ts('sent_at').notNull().defaultNow(), +}); + +export const parserRuns = pgTable('parser_runs', { + id: serial('id').primaryKey(), + startedAt: ts('started_at').notNull().defaultNow(), + finishedAt: ts('finished_at'), + /** running | ok | failed */ + status: text('status').notNull().default('running'), + foundNew: integer('found_new').notNull().default(0), + errors: text('errors'), +}); + +/** + * Объявления, которые парсер проверил и не взял в базу (старше 3 дней). Хранится только ID, + * чтобы не перечитывать страницу объявления при каждом «поднятии» в списке. + */ +export const seenListings = pgTable('seen_listings', { + externalId: text('external_id').primaryKey(), + seenAt: ts('seen_at').notNull().defaultNow(), +}); + +/** Какие категории list.am уже прочитаны хотя бы раз: первый проход только запоминает объявления. */ +export const parserState = pgTable('parser_state', { + categoryId: integer('category_id').primaryKey(), + baselineAt: ts('baseline_at').notNull().defaultNow(), + /** Порядок обычных объявлений в списке на прошлом проходе: по сдвигу в нём видно, что объявление подняли. */ + topIds: jsonb('top_ids').$type(), +}); + +/** + * Очередь сообщений Telegram. Воркер отправляет их с учётом лимитов Bot API и повторяет при ошибке. + * kind: lead — уведомление о лиде с кнопкой «Беру»; edit — обновить такое уведомление; text — обычный текст. + */ +export const outbox = pgTable( + 'outbox', + { + id: serial('id').primaryKey(), + kind: text('kind').notNull(), + /** null — получатель без аккаунта (написал в поддержку, не привязав Telegram): чат указан в payload.chatId. */ + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), + leadId: integer('lead_id').references(() => leads.id, { onDelete: 'cascade' }), + payload: jsonb('payload').$type>().notNull().default({}), + attempts: integer('attempts').notNull().default(0), + runAt: ts('run_at').notNull().defaultNow(), + doneAt: ts('done_at'), + error: text('error'), + createdAt, + }, + (t) => [index('outbox_due_idx').on(t.runAt).where(sql`${t.doneAt} is null`)], +); + +/** Настройки сервиса, которые суперадмин меняет в админке: одна строка на раздел (key = 'parser'). */ +export const settings = pgTable('settings', { + key: text('key').primaryKey(), + value: jsonb('value').$type>().notNull(), + updatedAt: ts('updated_at').notNull().defaultNow(), +}); + +/** + * Обращение в поддержку: человек написал боту. Пока обращение открыто, все его сообщения идут в него; + * суперадмин отвечает из админки и закрывает кнопкой «Проблема решена». + */ +export const supportTickets = pgTable( + 'support_tickets', + { + id: serial('id').primaryKey(), + /** Чат Telegram, из которого пишут. */ + chatId: bigint('chat_id', { mode: 'number' }).notNull(), + /** null — Telegram не привязан ни к одному аккаунту. */ + userId: integer('user_id').references(() => users.id), + /** Имя и ник из Telegram — чтобы понимать, кто пишет, даже без аккаунта. */ + tgName: text('tg_name'), + tgUsername: text('tg_username'), + /** open | closed */ + status: text('status').notNull().default('open'), + /** Есть сообщения, которые поддержка ещё не открывала. */ + unread: boolean('unread').notNull().default(true), + lastMessageAt: ts('last_message_at').notNull().defaultNow(), + closedAt: ts('closed_at'), + createdAt, + }, + (t) => [ + index('support_tickets_status_idx').on(t.status, t.lastMessageAt), + uniqueIndex('support_tickets_open_uq').on(t.chatId).where(sql`${t.status} = 'open'`), + ], +); + +export const supportMessages = pgTable( + 'support_messages', + { + id: serial('id').primaryKey(), + ticketId: integer('ticket_id').notNull().references(() => supportTickets.id, { onDelete: 'cascade' }), + /** true — ответ поддержки, false — сообщение пользователя. */ + fromSupport: boolean('from_support').notNull().default(false), + authorId: integer('author_id').references(() => users.id), + text: text('text'), + /** Вложение из Telegram: файл хранится у Telegram, здесь только его идентификатор. */ + fileId: text('file_id'), + /** photo | file */ + fileKind: text('file_kind'), + fileName: text('file_name'), + at: ts('at').notNull().defaultNow(), + }, + (t) => [index('support_messages_ticket_idx').on(t.ticketId)], +); + +/** + * Постоянная ссылка агента на анкету в клиентском боте: t.me/<бот>?start=. + * Каждый, кто заполнит анкету по ней, становится клиентом этого агента. + */ +export const intakeLinks = pgTable( + 'intake_links', + { + token: text('token').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + userId: integer('user_id').notNull().references(() => users.id), + createdAt, + }, + (t) => [uniqueIndex('intake_links_user_uq').on(t.userId)], +); + +/** Заполнение анкеты одним человеком: на каком он шаге и что уже ответил. */ +export const intakeSessions = pgTable( + 'intake_sessions', + { + id: serial('id').primaryKey(), + linkToken: text('link_token').notNull().references(() => intakeLinks.token), + /** Чат соискателя в клиентском боте. */ + chatId: bigint('chat_id', { mode: 'number' }).notNull(), + tgName: text('tg_name'), + tgUsername: text('tg_username'), + lang: text('lang').notNull().default('ru'), + /** Текущий вопрос анкеты; done — анкета заполнена. */ + step: text('step').notNull(), + answers: jsonb('answers').$type>().notNull().default({}), + /** Клиент, созданный по анкете. */ + clientId: integer('client_id').references(() => clients.id, { onDelete: 'set null' }), + completedAt: ts('completed_at'), + createdAt, + }, + (t) => [index('intake_sessions_chat_idx').on(t.chatId)], +); + +/** Страница подборки для клиента: объекты на выбор и запись на показ. Открывается по секретной ссылке без входа. */ +export const clientPages = pgTable( + 'client_pages', + { + token: text('token').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }), + /** Кто создал ссылку — ему приходят уведомления о записях на показ. */ + createdById: integer('created_by_id').notNull().references(() => users.id), + revokedAt: ts('revoked_at'), + createdAt, + }, + (t) => [uniqueIndex('client_pages_client_uq').on(t.clientId)], +); + +/** Запись клиента на показ объекта: день выбирает сам клиент на странице подборки. */ +export const viewings = pgTable( + 'viewings', + { + id: serial('id').primaryKey(), + companyId: integer('company_id').notNull().references(() => companies.id), + clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }), + leadId: integer('lead_id').notNull().references(() => leads.id, { onDelete: 'cascade' }), + day: date('day').notNull(), + note: text('note'), + createdAt, + }, + (t) => [uniqueIndex('viewings_uq').on(t.clientId, t.leadId)], +); + +/** + * Телефоны собственника, которые агенты внесли по объявлению: по одному варианту от компании. + * В общей базе хранятся все варианты; чем больше компаний указали один и тот же номер, тем он надёжнее. + */ +export const listingPhones = pgTable( + 'listing_phones', + { + id: serial('id').primaryKey(), + listingId: integer('listing_id').notNull().references(() => listings.id, { onDelete: 'cascade' }), + companyId: integer('company_id').notNull().references(() => companies.id), + userId: integer('user_id').references(() => users.id), + phone: text('phone').notNull(), + createdAt, + }, + (t) => [uniqueIndex('listing_phones_uq').on(t.listingId, t.companyId), index('listing_phones_phone_idx').on(t.listingId, t.phone)], +); diff --git a/apps/api/src/deals/service.ts b/apps/api/src/deals/service.ts new file mode 100644 index 0000000..4e6b47e --- /dev/null +++ b/apps/api/src/deals/service.ts @@ -0,0 +1,197 @@ +import { CURRENCIES, type DealKind } from '@lr/shared'; +import { and, eq, or } from 'drizzle-orm'; +import { z } from 'zod'; +import type { Tx } from '../db/client.ts'; +import { clientLeads, clients, deals, events, leads } from '../db/schema.ts'; +import { badRequest, conflict } from '../lib/errors.ts'; +import { pipelineOf } from '../pipelines/service.ts'; + +// Сделка — одна запись, даже когда в ней участвуют и объект, и клиент из CRM: комиссия вводится +// один раз и не задваивается в аналитике и биллинге. Комиссия состоит из двух частей: с собственника +// (идёт агенту объекта) и с покупателя или арендатора (идёт агенту клиента). + +/** Сделка в запросе. `commission` — прежний формат одной суммой: она относится к стороне, с которой закрывают сделку. */ +export const DealBody = z.object({ + ownerCommission: z.number().min(0).optional(), + clientCommission: z.number().min(0).optional(), + commission: z.number().positive().optional(), + currency: z.enum(CURRENCIES), + date: z.iso.date(), +}); +export type DealBody = z.infer; + +export interface DealInput { + ownerCommission: number; + clientCommission: number; + /** Вся комиссия агентства по сделке. */ + commission: number; + currency: string; + date: string; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** Части комиссии из запроса. `side` — с какой стороны закрывают сделку: туда идёт сумма без разбивки. */ +export function dealInput(body: DealBody, side: 'owner' | 'client'): DealInput { + const split = body.ownerCommission !== undefined || body.clientCommission !== undefined; + const ownerCommission = split ? (body.ownerCommission ?? 0) : side === 'owner' ? (body.commission ?? 0) : 0; + const clientCommission = split ? (body.clientCommission ?? 0) : side === 'client' ? (body.commission ?? 0) : 0; + const commission = round2(ownerCommission + clientCommission); + if (!(commission > 0)) throw badRequest('COMMISSION_REQUIRED'); + return { ownerCommission, clientCommission, commission, currency: body.currency, date: body.date }; +} + +export const dealText = (d: Pick) => `${d.commission} ${d.currency}`; + +/** Сделка в карточке объекта и клиента: части комиссии и кому они идут. `names` — имена участников по ID. */ +export function dealView(d: typeof deals.$inferSelect, names: Map) { + return { + id: d.id, + commission: d.commission, + ownerCommission: d.ownerCommission, + clientCommission: d.clientCommission, + ownerAgentName: names.get(String(d.ownerAgentId)) ?? null, + clientAgentName: names.get(String(d.clientAgentId)) ?? null, + currency: d.currency, + date: d.dealDate, + kind: d.kind, + }; +} + +/** + * Кому идут части комиссии и вид сделки. Часть с собственника — агенту объекта, часть с клиента — + * агенту клиента; если одной из сторон в CRM нет, обе части получает агент второй стороны. + */ +async function dealSides(tx: Tx, leadId: number | null, clientId: number | null, fallbackAgentId: number) { + const [lead] = leadId !== null ? await tx.select({ assigneeId: leads.assigneeId, dealType: leads.dealType }).from(leads).where(eq(leads.id, leadId)) : []; + const [client] = clientId !== null ? await tx.select({ assigneeId: clients.assigneeId, kind: clients.kind }).from(clients).where(eq(clients.id, clientId)) : []; + const kind: DealKind = lead?.dealType ? (lead.dealType === 'rent' ? 'rent' : 'sale') : client?.kind === 'tenant' ? 'rent' : 'sale'; + return { + ownerAgentId: lead?.assigneeId ?? client?.assigneeId ?? fallbackAgentId, + clientAgentId: client?.assigneeId ?? lead?.assigneeId ?? fallbackAgentId, + kind, + }; +} + +/** + * Записывает сделку для объекта, клиента или пары. Если у сторон уже были отдельные записи + * (каждую закрыли без пары), они сливаются в одну. + */ +export async function recordDeal( + tx: Tx, + v: { companyId: number; leadId: number | null; clientId: number | null; agentId: number; deal: DealInput }, +) { + if (v.leadId === null && v.clientId === null) throw new Error('Сделка без объекта и клиента'); + const rows = await tx + .select() + .from(deals) + .where( + and( + eq(deals.companyId, v.companyId), + or( + v.leadId !== null ? eq(deals.leadId, v.leadId) : undefined, + v.clientId !== null ? eq(deals.clientId, v.clientId) : undefined, + ), + ), + ); + // Сторона уже закрыта сделкой с другой парой: сначала ту сделку нужно отменить. + for (const r of rows) { + const otherClient = v.clientId !== null && r.clientId !== null && r.clientId !== v.clientId; + const otherLead = v.leadId !== null && r.leadId !== null && r.leadId !== v.leadId; + if (otherClient || otherLead) throw conflict('DEAL_HAS_PAIR'); + } + const [keep, extra] = rows; + const leadId = v.leadId ?? keep?.leadId ?? null; + const clientId = v.clientId ?? keep?.clientId ?? null; + const values = { + commission: v.deal.commission, + ownerCommission: v.deal.ownerCommission, + clientCommission: v.deal.clientCommission, + currency: v.deal.currency, + dealDate: v.deal.date, + ...(await dealSides(tx, leadId, clientId, v.agentId)), + }; + if (extra) await tx.delete(deals).where(eq(deals.id, extra.id)); + if (keep) { + await tx.update(deals).set({ ...values, leadId, clientId }).where(eq(deals.id, keep.id)); + return keep.id; + } + const [row] = await tx + .insert(deals) + .values({ companyId: v.companyId, leadId, clientId, agentId: v.agentId, ...values }) + .returning({ id: deals.id }); + return row!.id; +} + +/** + * Сторона вышла из статуса «Сделка». Если сделка была парной, запись остаётся за второй стороной + * и её агент получает обе части комиссии; иначе запись удаляется, чтобы комиссия не попала + * в аналитику и счёт. + */ +export async function dropDealSide(tx: Tx, side: 'lead' | 'client', id: number) { + const column = side === 'lead' ? deals.leadId : deals.clientId; + const [row] = await tx.select().from(deals).where(eq(column, id)); + if (!row) return; + const other = side === 'lead' ? row.clientId : row.leadId; + if (other === null) await tx.delete(deals).where(eq(deals.id, row.id)); + else { + await tx + .update(deals) + .set(side === 'lead' ? { leadId: null, ownerAgentId: row.clientAgentId } : { clientId: null, clientAgentId: row.ownerAgentId }) + .where(eq(deals.id, row.id)); + } +} + +/** Пара для сделки должна быть прикреплена: объект — к клиенту. */ +async function assertLinked(tx: Tx, clientId: number, leadId: number) { + const [link] = await tx + .select() + .from(clientLeads) + .where(and(eq(clientLeads.clientId, clientId), eq(clientLeads.leadId, leadId))); + if (!link) throw badRequest('PAIR_NOT_LINKED'); +} + +/** Клиент из пары переходит в «Сделка» вместе с объектом. */ +export async function moveClientToDeal(tx: Tx, companyId: number, userId: number, leadId: number, clientId: number, deal: DealInput) { + await assertLinked(tx, clientId, leadId); + const [client] = await tx + .select() + .from(clients) + .where(and(eq(clients.id, clientId), eq(clients.companyId, companyId))) + .for('update'); + if (!client) throw badRequest('PAIR_NOT_LINKED'); + const statuses = await pipelineOf(tx, companyId, 'client'); + const to = statuses.find((s) => s.code === 'deal')!; + if (client.statusId === to.id) return; + await tx + .update(clients) + .set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date() }) + .where(eq(clients.id, clientId)); + await tx.insert(events).values({ + companyId, clientId, userId, type: 'deal', + oldValue: statuses.find((s) => s.id === client.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal), + }); +} + +/** Объект из пары переходит в «Сделка» вместе с клиентом. Объект должен быть в работе, не в пуле. */ +export async function moveLeadToDeal(tx: Tx, companyId: number, userId: number, clientId: number, leadId: number, deal: DealInput) { + await assertLinked(tx, clientId, leadId); + const [lead] = await tx + .select() + .from(leads) + .where(and(eq(leads.id, leadId), eq(leads.companyId, companyId))) + .for('update'); + if (!lead) throw badRequest('PAIR_NOT_LINKED'); + if (lead.assigneeId === null) throw badRequest('TAKE_FIRST'); + const statuses = await pipelineOf(tx, companyId, 'object'); + const to = statuses.find((s) => s.code === 'deal')!; + if (lead.statusId === to.id) return; + await tx + .update(leads) + .set({ statusId: to.id, rejectReasonId: null, statusChangedAt: new Date(), poolWarnedAt: null }) + .where(eq(leads.id, leadId)); + await tx.insert(events).values({ + companyId, leadId, userId, type: 'deal', + oldValue: statuses.find((s) => s.id === lead.statusId)?.name ?? null, newValue: to.name, statusId: to.id, text: dealText(deal), + }); +} diff --git a/apps/api/src/deep-pass.int.test.ts b/apps/api/src/deep-pass.int.test.ts new file mode 100644 index 0000000..74b1862 --- /dev/null +++ b/apps/api/src/deep-pass.int.test.ts @@ -0,0 +1,60 @@ +import { inArray } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { listings } from './db/schema.ts'; +import { runIngest, type PagedSource } from './ingest/service.ts'; +import { LISTAM_CATEGORIES } from './parser/listam-source.ts'; +import { loadParserSettings, saveParserSettings } from './parser/settings.ts'; +import type { SourcedListing } from './parser/source.ts'; +import { testApp } from './testing.ts'; + +// Глубокий проход: догоняем объявления, которые вышли, пока парсер стоял, и ушли со страницы 1. + +const RENT = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!; +let ext = 995_000_000; +const card = (daysAgo: number): SourcedListing => { + const id = String(++ext); + return { + externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '2-комн. квартира', dealType: 'rent', + propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 300_000, currency: 'AMD', rooms: 2, + area: 50, floor: 3, floorsTotal: 9, isAgency: false, photoUrls: [], publishedAt: new Date(Date.now() - daysAgo * 86_400_000), + }; +}; + +/** Список категории постранично, по 2 объявления на странице. */ +let list: SourcedListing[] = []; +const source: PagedSource = { + name: 'fake', + item: async () => null, + listPage: async (category, page = 1) => (category.id === RENT.id ? list.slice((page - 1) * 2, page * 2) : []), + inspect: async (c) => c, +}; + +let t: Awaited>; +beforeAll(async () => { t = await testApp('deep_pass', source); }); +afterAll(() => t.close()); + +const inBase = async (items: SourcedListing[]) => + (await t.db.select({ id: listings.externalId }).from(listings).where(inArray(listings.externalId, items.map((i) => i.externalId)))).map((r) => r.id).sort(); + +describe('глубокий проход парсера', () => { + it('подбирает свежие объявления из глубины списка, старые пропускает, затем выключается', async () => { + await saveParserSettings(t.db, { categories: [RENT.id], maxPages: 5, freshDays: 3 }); + const known = [card(5), card(5)]; + list = known; + await runIngest({ db: t.db, source }); // первый проход по категории — только запоминает + await runIngest({ db: t.db, source }); + + // Пока парсер стоял, вышли объявления; сверху снова известные — обычный проход дальше страницы 1 не идёт. + const missed = [card(1), card(2)]; + const stale = card(10); + list = [...known, ...missed, stale]; + await runIngest({ db: t.db, source }); + expect(await inBase(missed)).toEqual([]); + + await saveParserSettings(t.db, { deepPass: true }); + await runIngest({ db: t.db, source }); + expect(await inBase(missed)).toEqual(missed.map((m) => m.externalId).sort()); + expect(await inBase([stale])).toEqual([]); + expect((await loadParserSettings(t.db)).deepPass).toBe(false); + }); +}); diff --git a/apps/api/src/departments.int.test.ts b/apps/api/src/departments.int.test.ts new file mode 100644 index 0000000..a600e14 --- /dev/null +++ b/apps/api/src/departments.int.test.ts @@ -0,0 +1,259 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { leads, listings, users } from './db/schema.ts'; +import { testApp } from './testing.ts'; + +// Неактуальные объекты, «Отправить в бот», избранное и «Не просмотренные», отделы, цена в АМД и ДОЛ. + +let t: Awaited>; +let tgId = 50_000; +let extId = 7_000_000; + +beforeAll(async () => { t = await testApp('departments'); }); +afterAll(() => t.close()); + +type Api = ReturnType; +interface Member { api: Api; id: number; chat: number | null } + +/** Компания с руководителем и агентами; Telegram привязан у всех, кроме перечисленных в noTelegram. */ +const company = async (agents = 2, noTelegram: number[] = []) => { + // Объявления прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы. + await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null }); + const [admin] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + const sa = t.client(await createSession(t.db, admin!.id)); + const created = await sa.post('/api/admin/companies', { + name: 'Агентство', plan: 'agency', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const join = async (name: string, token: string, linked = true): Promise => { + const api = await t.register(name); + await api.post(`/api/invites/${token}/accept`); + const id = (await api.get('/api/me')).body.user.id as number; + const chat = linked ? ++tgId : null; + if (chat) await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id)); + return { api, id, chat }; + }; + const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!); + const invite = (await boss.api.post('/api/invites')).body.token as string; + const team: Member[] = []; + for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite, !noTelegram.includes(i))); + return { sa, boss, agents: team, id: created.body.id as number }; +}; + +/** Свежее объявление в общей базе: его получит новый фильтр. */ +const addListing = async (over: Partial = {}) => { + const id = String(++extId); + const [row] = await t.db + .insert(listings) + .values({ + externalId: id, url: `https://www.list.am/item/${id}`, title: `Квартира ${id}`, dealType: 'sale', propertyType: 'apartment', + region: 'Ереван', district: 'Кентрон', price: 100_000, currency: 'USD', rooms: 2, area: 60, isAgency: false, + photos: ['https://img.list.am/f/1.webp'], publishedAt: new Date(), ...over, + }) + .returning(); + return row!; +}; + +const titles = async (api: Api, query = 'tab=all') => ((await api.get(`/api/leads?${query}`)).body as { title: string }[]).map((l) => l.title).sort(); + +describe('неактуальные объекты', () => { + it('снятые с List.am и закрытые объекты не попадают в списки, поиск и подбор, но остаются в «Базе»', async () => { + const c = await company(1); + const live = await addListing({ title: 'Живой объект' }); + const gone = await addListing({ title: 'Снятый объект' }); + await addListing({ title: 'Отказной объект' }); + await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } }); + expect(await titles(c.boss.api)).toEqual(['Живой объект', 'Отказной объект', 'Снятый объект']); + + // Объявление сняли с List.am; второй объект закрыли отказом. + await t.db.update(listings).set({ active: false, removedAt: new Date() }).where(eq(listings.id, gone.id)); + const all = (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; title: string }[]; + const rejectedId = all.find((l) => l.title === 'Отказной объект')!.id; + await c.boss.api.post(`/api/leads/${rejectedId}/take`); + const pipeline = (await c.boss.api.get('/api/pipelines/object')).body; + const rejected = pipeline.statuses.find((s: { code: string }) => s.code === 'rejected'); + expect((await c.boss.api.post(`/api/leads/${rejectedId}/status`, { statusId: rejected.id, rejectReasonId: pipeline.reasons[0].id })).status).toBe(200); + + expect(await titles(c.boss.api)).toEqual(['Живой объект']); + expect(await titles(c.boss.api, 'tab=all&q=Снятый')).toEqual([]); + expect(await titles(c.agents[0]!.api, 'tab=pool')).toEqual(['Живой объект']); + expect((await c.boss.api.get('/api/leads-summary')).body).toMatchObject({ all: 1, pool: 1 }); + + // В подбор клиенту снятый объект не попадает. + const client = await c.boss.api.post('/api/clients', { name: 'Покупатель', kind: 'buyer', request: {} }); + const suggested = (await c.boss.api.get(`/api/clients/${client.body.id}/suggestions`)).body as { title: string }[]; + expect(suggested.map((s) => s.title)).toEqual(['Живой объект']); + + // Данные не удалены: снятый объект — в «Базе», карточка открывается. + const removed = (await c.boss.api.get('/api/archive/leads?result=removed')).body as { title: string }[]; + expect(removed.map((l) => l.title)).toEqual(['Снятый объект']); + const archive = (await c.boss.api.get('/api/archive/leads')).body as { title: string }[]; + expect(archive.map((l) => l.title).sort()).toEqual(['Отказной объект', 'Снятый объект']); + const [goneLead] = await t.db.select().from(leads).where(eq(leads.listingId, gone.id)); + expect((await c.boss.api.get(`/api/leads/${goneLead!.id}`)).status).toBe(200); + void live; + }); +}); + +describe('«Отправить в бот»', () => { + it('объект уходит в личный чат того, кто нажал; без привязки Telegram — понятная ошибка', async () => { + const c = await company(2, [1]); + await addListing({ title: 'Для бота' }); + await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } }); + const [lead] = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body as { id: number }[]; + + const before = t.groupSent.length; + expect((await c.agents[0]!.api.post(`/api/leads/${lead!.id}/bot`)).status).toBe(200); + const sent = t.groupSent.slice(before); + // Фото альбомом и два текста — все в чат нажавшего, никому больше. + expect(sent.map((m) => m.kind)).toEqual(['album', 'text', 'text']); + expect(new Set(sent.map((m) => m.chatId))).toEqual(new Set([c.agents[0]!.chat])); + expect(sent[1]!.text).toContain('Для бота'); + + // Другой агент нажал ту же кнопку — объект пришёл ему, а не первому. + const mark = t.groupSent.length; + await c.boss.api.post(`/api/leads/${lead!.id}/bot`); + expect(new Set(t.groupSent.slice(mark).map((m) => m.chatId))).toEqual(new Set([c.boss.chat])); + + // Telegram не привязан. + const res = await c.agents[1]!.api.post(`/api/leads/${lead!.id}/bot`); + expect(res).toMatchObject({ status: 400, body: { code: 'TELEGRAM_NOT_LINKED' } }); + }); +}); + +describe('избранное и «Не просмотренные»', () => { + it('фильтры работают у каждого своим и вместе с остальными фильтрами', async () => { + const c = await company(1); + await addListing({ title: 'Дешёвая', price: 50_000 }); + await addListing({ title: 'Дорогая', price: 300_000 }); + await addListing({ title: 'Средняя', price: 120_000 }); + await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } }); + const agent = c.agents[0]!.api; + const pool = (await agent.get('/api/leads?tab=pool')).body as { id: number; title: string; favorite: boolean; seen: boolean }[]; + const id = (title: string) => pool.find((l) => l.title === title)!.id; + expect(pool.every((l) => !l.favorite && !l.seen)).toBe(true); + + await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: true }); + await agent.post(`/api/leads/${id('Дорогая')}/favorite`, { on: true }); + expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дешёвая', 'Дорогая']); + // Вместе с ценой. + expect(await titles(agent, 'tab=pool&favorite=1&priceFrom=100000')).toEqual(['Дорогая']); + // Избранное своё у каждого. + expect(await titles(c.boss.api, 'tab=all&favorite=1')).toEqual([]); + + // Открытая карточка больше не «непросмотренная» — только для того, кто её открыл. + expect((await agent.get(`/api/leads/${id('Средняя')}`)).body.favorite).toBe(false); + expect((await agent.get(`/api/leads/${id('Дорогая')}`)).body.favorite).toBe(true); + expect(await titles(agent, 'tab=pool&unseen=1')).toEqual(['Дешёвая']); + expect(await titles(agent, 'tab=pool&unseen=1&favorite=1')).toEqual(['Дешёвая']); + expect(await titles(c.boss.api, 'tab=all&unseen=1')).toEqual(['Дешёвая', 'Дорогая', 'Средняя']); + + await agent.post(`/api/leads/${id('Дешёвая')}/favorite`, { on: false }); + expect(await titles(agent, 'tab=pool&favorite=1')).toEqual(['Дорогая']); + }); +}); + +describe('цена в АМД и ДОЛ', () => { + it('границы в выбранной валюте; цены объектов пересчитываются по курсу', async () => { + const c = await company(0); + // Курс по умолчанию: 385 драмов за доллар. + await addListing({ title: 'В долларах', price: 100_000, currency: 'USD' }); // 38 500 000 ֏ + await addListing({ title: 'В драмах', price: 30_000_000, currency: 'AMD' }); // ≈ $77 922 + await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { ownersOnly: false } }); + const api = c.boss.api; + expect(await titles(api, 'tab=all&priceFrom=80000&priceCurrency=USD')).toEqual(['В долларах']); + expect(await titles(api, 'tab=all&priceTo=80000&priceCurrency=USD')).toEqual(['В драмах']); + expect(await titles(api, 'tab=all&priceFrom=35000000&priceCurrency=AMD')).toEqual(['В долларах']); + expect(await titles(api, 'tab=all&priceFrom=29000000&priceTo=31000000&priceCurrency=AMD')).toEqual(['В драмах']); + expect(await titles(api, 'tab=all&priceFrom=1000&priceCurrency=AMD')).toEqual(['В долларах', 'В драмах']); + expect((await api.get('/api/rates')).body).toMatchObject({ USD: 1, AMD: 385 }); + }); +}); + +describe('отделы', () => { + it('у отдела свои фильтры, объекты и клиенты; сотрудник не видит данные другого отдела', async () => { + const c = await company(3); + const [a1, a2, free] = c.agents as [Member, Member, Member]; + // Отделы создаёт и наполняет только руководитель. + expect((await a1.api.post('/api/departments', { name: 'Свой' })).status).toBe(403); + const sale = (await c.boss.api.post('/api/departments', { name: 'Продажа' })).body.id as number; + const rent = (await c.boss.api.post('/api/departments', { name: 'Аренда' })).body.id as number; + expect((await a1.api.put(`/api/members/${a2.id}/department`, { departmentId: sale })).status).toBe(403); + expect((await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: sale })).status).toBe(200); + expect((await c.boss.api.put(`/api/members/${a2.id}/department`, { departmentId: rent })).status).toBe(200); + expect((await a1.api.get('/api/me')).body.user).toMatchObject({ departmentId: sale, departmentName: 'Продажа' }); + const members = (await c.boss.api.get('/api/members')).body as { id: number; departmentId: number | null }[]; + expect(members.find((m) => m.id === a1.id)!.departmentId).toBe(sale); + + // Фильтры отделов задаёт руководитель; агент видит только фильтры своего отдела. + await addListing({ title: 'Продажа 1', dealType: 'sale' }); + await addListing({ title: 'Аренда 1', dealType: 'rent', price: 800 }); + expect((await a1.api.post('/api/filters', { name: 'x', scope: 'department', departmentId: sale, params: {} })).status).toBe(403); + await c.boss.api.post('/api/filters', { name: 'Продажи', scope: 'department', departmentId: sale, params: { dealType: 'sale', ownersOnly: false } }); + await c.boss.api.post('/api/filters', { name: 'Аренда', scope: 'department', departmentId: rent, params: { dealType: 'rent', ownersOnly: false } }); + const f1 = (await a1.api.get('/api/filters')).body as { name: string; scope: string; editable: boolean }[]; + expect(f1).toEqual([expect.objectContaining({ name: 'Продажи', scope: 'department', editable: false })]); + expect(((await c.boss.api.get('/api/filters')).body as unknown[]).length).toBe(2); + + // Объекты пришли каждому отделу по его фильтру и не пересекаются. + expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']); + expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1']); + // Сотрудник без отдела объекты отделов не видит; руководитель видит все и может выбрать отдел. + expect(await titles(free.api, 'tab=pool')).toEqual([]); + expect(await titles(c.boss.api)).toEqual(['Аренда 1', 'Продажа 1']); + expect(await titles(c.boss.api, `tab=all&departmentId=${rent}`)).toEqual(['Аренда 1']); + + // Чужой объект нельзя открыть, взять, отправить себе в бот или добавить в избранное. + const rentLead = ((await a2.api.get('/api/leads?tab=pool')).body as { id: number }[])[0]!.id; + expect((await a1.api.get(`/api/leads/${rentLead}`)).status).toBe(404); + expect((await a1.api.post(`/api/leads/${rentLead}/take`)).status).toBe(404); + expect((await a1.api.post(`/api/leads/${rentLead}/bot`)).status).toBe(404); + expect((await a1.api.post(`/api/leads/${rentLead}/favorite`, { on: true })).status).toBe(404); + + // Одно объявление под фильтрами двух отделов — у каждого отдела своя карточка. + await c.boss.api.post('/api/filters', { name: 'Продажи 2', scope: 'department', departmentId: rent, params: { dealType: 'sale', ownersOnly: false } }); + expect(await titles(a2.api, 'tab=pool')).toEqual(['Аренда 1', 'Продажа 1']); + expect(await titles(a1.api, 'tab=pool')).toEqual(['Продажа 1']); + const saleIn = async (api: Api) => ((await api.get('/api/leads?tab=pool&q=Продажа')).body as { id: number }[])[0]!.id; + expect(await saleIn(a1.api)).not.toBe(await saleIn(a2.api)); + + // Клиенты отдела: агент видит клиентов своего отдела, но не чужого (видимость «видят все» включена). + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + const own = await a1.api.post('/api/clients', { name: 'Клиент продажи', kind: 'buyer', request: {} }); + const other = await a2.api.post('/api/clients', { name: 'Клиент аренды', kind: 'tenant', request: {} }); + const clientNames = async (api: Api) => ((await api.get('/api/clients?tab=all')).body as { name: string }[]).map((x) => x.name).sort(); + expect(await clientNames(a1.api)).toEqual(['Клиент продажи']); + expect(await clientNames(a2.api)).toEqual(['Клиент аренды']); + expect(await clientNames(c.boss.api)).toEqual(['Клиент аренды', 'Клиент продажи']); + expect((await a1.api.get(`/api/clients/${other.body.id}`)).status).toBe(404); + // С «видят все» агент видит все объекты, но только своего отдела. + expect(await titles(a1.api)).toEqual(['Продажа 1']); + // Подбор клиенту — только из объектов его отдела. + const sug = (await c.boss.api.get(`/api/clients/${own.body.id}/suggestions`)).body as { id: number }[]; + expect(sug.map((s) => s.id)).toEqual([await saleIn(a1.api)]); + + // Агента перевели в другой отдел: его объекты в работе и клиенты переходят с ним. + await a1.api.post(`/api/leads/${await saleIn(a1.api)}/take`); + const taken = ((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[])[0]!.id; + const moved = await c.boss.api.put(`/api/members/${a1.id}/department`, { departmentId: null }); + // В «без отдела» карточки по этому объявлению нет — объект переходит. + expect(moved.body).toMatchObject({ ok: true, kept: 0 }); + expect(((await a1.api.get('/api/leads?tab=mine')).body as { id: number }[]).map((l) => l.id)).toEqual([taken]); + expect(await clientNames(a1.api)).toEqual(['Клиент продажи']); + expect(await clientNames(a2.api)).toEqual(['Клиент аренды']); + + // Непустой отдел не удаляется; пустой — удаляется вместе с фильтрами. + expect((await c.boss.api.del(`/api/departments/${rent}`)).body).toMatchObject({ code: 'DEPARTMENT_NOT_EMPTY' }); + const empty = (await c.boss.api.post('/api/departments', { name: 'Пустой' })).body.id as number; + await c.boss.api.post('/api/filters', { name: 'Пустой', scope: 'department', departmentId: empty, params: {} }); + expect((await c.boss.api.del(`/api/departments/${empty}`)).status).toBe(200); + expect(((await c.boss.api.get('/api/filters')).body as { name: string }[]).map((f) => f.name)).not.toContain('Пустой'); + const list = (await c.boss.api.get('/api/departments')).body as { name: string; members: number }[]; + expect(list.map((d) => [d.name, d.members])).toEqual([['Аренда', 1], ['Продажа', 0]]); + expect(((await a2.api.get('/api/departments')).body as { name: string }[]).map((d) => d.name)).toEqual(['Аренда']); + }); +}); diff --git a/apps/api/src/departments/routes.ts b/apps/api/src/departments/routes.ts new file mode 100644 index 0000000..2c662c9 --- /dev/null +++ b/apps/api/src/departments/routes.ts @@ -0,0 +1,136 @@ +import { and, asc, eq, inArray, isNull, not, sql } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { clients, departments, events, filters, leads, users } from '../db/schema.ts'; +import type { Guards } from '../leads/routes.ts'; +import { badRequest, conflict, notFound } from '../lib/errors.ts'; +import { inDepartment } from '../leads/service.ts'; + +// Отделы компании. Создаёт, переименовывает и удаляет отделы, назначает и снимает сотрудников руководитель +// (и суперадмин в кабинете компании). У отдела свои фильтры, объекты и клиенты: сотрудник видит только +// данные своего отдела, руководитель — все. + +const Id = z.object({ id: z.coerce.number().int().positive() }); +const Name = z.object({ name: z.string().trim().min(1).max(80) }); + +export function registerDepartmentRoutes(app: FastifyInstance, db: Db, g: Guards) { + type Ctx = ReturnType; + + const own = async (ctx: Ctx, id: number) => { + const [d] = await db.select().from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id))); + if (!d) throw notFound(); + return d; + }; + + /** Руководитель видит все отделы, сотрудник — только свой. */ + app.get('/api/departments', async (req) => { + const ctx = g.member(req); + const count = (table: string, column: string) => + sql`(select count(*)::int from ${sql.raw(table)} x where x.${sql.raw(column)} = "departments"."id"${sql.raw(table === 'users' ? ' and x.removed_at is null' : '')})`; + return db + .select({ + id: departments.id, + name: departments.name, + members: count('users', 'department_id'), + filters: count('filters', 'department_id'), + leads: count('leads', 'department_id'), + clients: count('clients', 'department_id'), + }) + .from(departments) + .where( + and( + eq(departments.companyId, ctx.company.id), + ctx.role === 'manager' ? undefined : ctx.user.departmentId ? eq(departments.id, ctx.user.departmentId) : sql`false`, + ), + ) + .orderBy(asc(departments.name), asc(departments.id)); + }); + + app.post('/api/departments', async (req) => { + const ctx = g.writable(g.manager(req)); + const { name } = Name.parse(req.body); + const [row] = await db.insert(departments).values({ companyId: ctx.company.id, name }).returning({ id: departments.id }); + return { id: row!.id }; + }); + + app.patch('/api/departments/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const d = await own(ctx, Id.parse(req.params).id); + const { name } = Name.parse(req.body); + await db.update(departments).set({ name }).where(eq(departments.id, d.id)); + return { ok: true }; + }); + + /** + * Удалить можно отдел без сотрудников, клиентов и объектов в работе. Объекты из его пула, которые никто + * не брал, удаляются вместе с ним (объявления остаются в общей базе), как и его фильтры: данные отдела + * не смешиваются с данными компании. + */ + app.delete('/api/departments/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const d = await own(ctx, Id.parse(req.params).id); + await db.transaction(async (tx) => { + const [busy] = await tx + .select({ + users: sql`(select count(*)::int from users u where u.department_id = ${d.id} and u.removed_at is null)`, + clients: sql`(select count(*)::int from clients c where c.department_id = ${d.id})`, + worked: sql`(select count(*)::int from leads l join pipeline_statuses ps on ps.id = l.status_id + where l.department_id = ${d.id} and (l.assignee_id is not null or l.taken_at is not null or ps.code is distinct from 'new'))`, + }) + .from(sql`(select 1) as one`); + if (busy && (busy.users || busy.clients || busy.worked)) throw conflict('DEPARTMENT_NOT_EMPTY'); + const pool = (await tx.select({ id: leads.id }).from(leads).where(eq(leads.departmentId, d.id))).map((l) => l.id); + if (pool.length) { + await tx.update(leads).set({ previousLeadId: null }).where(inArray(leads.previousLeadId, pool)); + await tx.delete(events).where(inArray(events.leadId, pool)); + await tx.delete(leads).where(inArray(leads.id, pool)); + } + await tx.delete(filters).where(eq(filters.departmentId, d.id)); + await tx.delete(departments).where(eq(departments.id, d.id)); + }); + return { ok: true }; + }); + + /** + * Назначить сотрудника в отдел или снять с отдела (departmentId = null). Объекты и клиенты, которые он + * ведёт, переходят вместе с ним — иначе он перестал бы их видеть. Объект не переносится, если в новом + * отделе уже есть карточка по тому же объявлению: его можно передать другому сотруднику старого отдела. + */ + app.put('/api/members/:id/department', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + const { departmentId } = z.object({ departmentId: z.number().int().positive().nullable() }).parse(req.body); + const [user] = await db + .select() + .from(users) + .where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt))); + if (!user) throw notFound(); + if (departmentId !== null) await own(ctx, departmentId).catch(() => { throw badRequest('BAD_DEPARTMENT'); }); + if ((user.departmentId ?? null) === departmentId) return { ok: true, kept: 0 }; + const kept = await db.transaction(async (tx) => { + await tx.update(users).set({ departmentId }).where(eq(users.id, id)); + const target = departmentId ?? 0; + await tx + .update(leads) + .set({ departmentId }) + .where( + and( + eq(leads.companyId, ctx.company.id), + eq(leads.assigneeId, id), + sql`not (${leads.listingId} is not null and ${leads.previousLeadId} is null and exists ( + select 1 from leads o where o.company_id = ${leads.companyId} and coalesce(o.department_id, 0) = ${target} + and o.listing_id = ${leads.listingId} and o.previous_lead_id is null and o.id <> ${leads.id}))`, + ), + ); + const [left] = await tx + .select({ n: sql`count(*)::int` }) + .from(leads) + .where(and(eq(leads.companyId, ctx.company.id), eq(leads.assigneeId, id), not(inDepartment(leads.departmentId, departmentId)))); + await tx.update(clients).set({ departmentId }).where(and(eq(clients.companyId, ctx.company.id), eq(clients.assigneeId, id))); + return left?.n ?? 0; + }); + // kept — сколько объектов осталось в прежнем отделе (там уже есть карточка того же объявления). + return { ok: true, kept }; + }); +} diff --git a/apps/api/src/group/service.ts b/apps/api/src/group/service.ts new file mode 100644 index 0000000..c1db69d --- /dev/null +++ b/apps/api/src/group/service.ts @@ -0,0 +1,222 @@ +import { expectedCommission, type CommissionRule, type Currency } from '@lr/shared'; +import { and, eq, isNotNull, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { companies, leads, pipelineStatuses, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts'; +import { renderShowcaseText } from '../showcase/text.ts'; +import { esc } from '../telegram/texts.ts'; +import { badRequest } from '../lib/errors.ts'; + +// Группа компании в Telegram: объект, который агент взял в работу и для которого указал комиссию, +// уходит туда своим ботом компании — все фото, описание для клиента, кто ведёт объект и комиссия. +// Комиссию поменяли — объект уходит ещё раз. + +/** Бот группы: токен, группа и, если в группе есть топики, номер нужного топика. */ +export type GroupBot = { token: string; chatId: number; threadId?: number }; + +/** Отправка в группу. В тестах подменяется записывающей заглушкой. */ +export interface GroupSender { + /** Фото альбомами по 10 штук. */ + album(bot: GroupBot, photos: Buffer[]): Promise; + text(bot: GroupBot, html: string): Promise; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Вызов Bot API. В группу Telegram пускает около 20 сообщений в минуту (альбом из 10 фото — это 10): + * на «подождите N секунд» ждём и повторяем тот же вызов, чтобы фото не задвоились. + */ +async function call(token: string, method: string, body: FormData | Record) { + for (let attempt = 0; ; attempt++) { + const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, { + method: 'POST', + ...(body instanceof FormData ? { body } : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }), + signal: AbortSignal.timeout(120_000), + }); + const data = (await res.json()) as { ok: boolean; description?: string; parameters?: { retry_after?: number } }; + if (data.ok) return; + const wait = data.parameters?.retry_after; + if (res.status === 429 && wait && attempt < 5) { + await sleep(Math.min(wait, 120) * 1000 + 500); + continue; + } + throw new Error(`Telegram ${method}: ${data.description ?? res.status}`); + } +} + +export const telegramGroupSender: GroupSender = { + async album({ token, chatId, threadId }, photos) { + for (let i = 0; i < photos.length; i += 10) { + const chunk = photos.slice(i, i + 10); + const form = new FormData(); + form.set('chat_id', String(chatId)); + if (threadId) form.set('message_thread_id', String(threadId)); + // Одно фото альбомом не отправить — оно уходит обычным сообщением. + if (chunk.length === 1) { + form.set('photo', new Blob([new Uint8Array(chunk[0]!)], { type: 'image/jpeg' }), 'photo.jpg'); + await call(token, 'sendPhoto', form); + continue; + } + form.set('media', JSON.stringify(chunk.map((_, k) => ({ type: 'photo', media: `attach://p${k}` })))); + chunk.forEach((data, k) => form.set(`p${k}`, new Blob([new Uint8Array(data)], { type: 'image/jpeg' }), `p${k}.jpg`)); + await call(token, 'sendMediaGroup', form); + } + }, + async text({ token, chatId, threadId }, html) { + await call(token, 'sendMessage', { chat_id: chatId, ...(threadId ? { message_thread_id: threadId } : {}), text: html, parse_mode: 'HTML', link_preview_options: { is_disabled: true } }); + }, +}; + +const SYMBOL: Record = { USD: '$', AMD: '֏', RUB: '₽', EUR: '€' }; +const money = (amount: number, currency: string) => { + // Разряды — обычным пробелом: так сумма одинаково выглядит в любом клиенте Telegram. + const n = Math.round(amount).toLocaleString('ru-RU').replace(/[\u00a0\u202f]/g, ' '); + return currency === 'AMD' || currency === 'RUB' ? `${n} ${SYMBOL[currency]}` : `${SYMBOL[currency] ?? ''}${n}`; +}; + +/** «50 % (≈ 175 000 ֏)», «$1 000», «$200 + 2 % (≈ $2 600)». */ +export function commissionLine(rule: CommissionRule, price: number | null, currency: string | null): string { + const terms = + rule.mode === 'percent' ? `${rule.value} %` : rule.mode === 'fixed' ? money(rule.value, rule.currency) : `${money(rule.fixed ?? 0, rule.currency)} + ${rule.value} %`; + const expected = rule.mode === 'fixed' ? null : expectedCommission(rule, price, currency as Currency | null); + return expected ? `${terms} (≈ ${money(expected.amount, expected.currency)})` : terms; +} + +type GroupLead = Pick & Parameters[0]; + +/** + * Сообщения в группу после фото: описание объекта (название и текст для клиента) и отдельно — + * кто дозвонился, комиссия и ссылка. + */ +export function groupTexts(lead: GroupLead, agentName: string | null, commission: CommissionRule | null, resent = false): [string, string] { + const about = [ + resent ? '🔁 Комиссия изменилась' : null, + `${esc(lead.title)}`, + '', + esc(lead.showcaseText ?? renderShowcaseText(lead)), + ]; + const info = [ + `👤 Дозвонился: ${esc(agentName ?? '—')}`, + `💰 Комиссия: ${commission ? esc(commissionLine(commission, lead.price, lead.currency)) : 'не указана'}`, + lead.url ? `🔗 ${esc(lead.url)}` : null, + ]; + const join = (lines: (string | null)[]) => lines.filter((line) => line !== null).join('\n').slice(0, 4096); + return [join(about), join(info)]; +} + +/** Фото и текст ещё готовятся (открытие карточки ставит их в очередь) — ждём до 10 минут, потом отправляем как есть. */ +const GALLERY_WAIT_MS = 10 * 60_000; + +type Lead = typeof leads.$inferSelect; +type PostDeps = { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date }; + +/** + * Объект в группу: фото альбомами, описание, отдельно — кто дозвонился и комиссия. Отмечает, с какой + * комиссией объект ушёл (своей, из карточки): автоматика с той же комиссией его больше не пришлёт. + */ +async function postLead(deps: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) { + const { db, now = new Date() } = deps; + await deliverLead(deps, bot, lead, agentName, commission, resent); + await db.update(leads).set({ groupPostedCommission: lead.commission, groupPostedAt: now }).where(eq(leads.id, lead.id)); +} + +/** Фото объекта альбомами и два сообщения: описание для клиента и кто ведёт объект, комиссия, ссылка. */ +async function deliverLead({ config, sender = telegramGroupSender, fetchImage }: PostDeps, bot: GroupBot, lead: Lead, agentName: string | null, commission: CommissionRule | null, resent = false) { + const photos: Buffer[] = []; + for (const url of lead.photos.filter((u) => /^https:\/\/(?:img|s)\.list\.am\//.test(u))) { + const photo = await croppedPhoto(config, url, fetchImage).catch(() => null); + if (photo) photos.push(photo); + } + if (photos.length) await sender.album(bot, photos); + for (const text of groupTexts(lead, agentName, commission, resent)) await sender.text(bot, text); +} + +/** + * Кнопка «Отправить в бот»: объект в том же виде, что и в группу, уходит основным ботом в личный чат + * того, кто нажал кнопку. Получатель — Telegram, привязанный к его аккаунту; выбрать другого нельзя. + */ +export async function sendLeadToUser(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead, user: Pick) { + const token = deps.config.telegramBotToken; + if (!token) throw badRequest('TELEGRAM_DISABLED'); + if (!user.telegramId) throw badRequest('TELEGRAM_NOT_LINKED'); + const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : []; + const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null; + try { + await deliverLead(deps, { token, chatId: user.telegramId }, lead, agent?.name ?? null, commission); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[bot] объект ${lead.id}, отправка себе: ${message}`); + // Бот заблокирован или чат с ним не начат: человеку нужно открыть бота и нажать «Старт». + if (/forbidden|chat not found|bot was blocked|user is deactivated/i.test(message)) throw badRequest('BOT_CHAT_UNAVAILABLE'); + throw badRequest('BOT_SEND_FAILED'); + } +} + +/** + * Кнопка «Отправить в Telegram» в карточке объекта: отправка сразу, с тем, что уже есть. Комиссия — + * из карточки, иначе из настроек компании. + */ +export async function sendLeadToGroup(deps: PostDeps, company: typeof companies.$inferSelect, lead: Lead) { + const bot = company.settings.groupBot; + if (!bot?.chatId) throw badRequest('GROUP_BOT_NOT_SET'); + const [agent] = lead.assigneeId ? await deps.db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : []; + const commission = lead.commission ?? company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale'] ?? null; + try { + await postLead(deps, bot, lead, agent?.name ?? null, commission); + } catch (error) { + console.error(`[group] объект ${lead.id}, ручная отправка: ${error instanceof Error ? error.message : String(error)}`); + throw badRequest('GROUP_SEND_FAILED'); + } +} + +/** Неудачная отправка (нет доступа к группе, сбой сети) повторяется не раньше чем через 10 минут. */ +const failedAt = new Map(); + +/** Раз в 20 секунд: объекты в работе, чья комиссия ещё не уходила в группу компании в таком виде. */ +export async function runGroupPosts({ db, config, sender = telegramGroupSender, fetchImage, now = new Date(), pause = 3_000 }: { db: Db; config: Config; sender?: GroupSender; fetchImage?: ImageFetcher; now?: Date; pause?: number }) { + const rows = await db + .select({ lead: leads, company: companies, agentName: users.name }) + .from(leads) + .innerJoin(companies, eq(companies.id, leads.companyId)) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .innerJoin(users, eq(users.id, leads.assigneeId)) + .where( + and( + sql`${companies.settings}->'groupBot'->>'chatId' is not null`, + isNotNull(leads.commission), + // В работе: взят и ещё не закрыт сделкой или отказом. + sql`coalesce(${pipelineStatuses.code}, '') not in ('new', 'rejected', 'deal')`, + sql`${leads.commission} is distinct from ${leads.groupPostedCommission}`, + ), + ) + // Очередь: раньше всех — объекты, которые ждут дольше. + .orderBy(sql`${leads.galleryWantedAt} asc nulls last`, leads.id) + .limit(5); + let sent = 0; + for (const { lead, company, agentName } of rows) { + const bot = company.settings.groupBot!; + if (now.getTime() - (failedAt.get(lead.id) ?? 0) < 10 * 60_000) continue; + const waiting = lead.listingId && !lead.galleryAt && lead.galleryTries < 3; + if (waiting) { + if (!lead.galleryWantedAt) { + await db.update(leads).set({ galleryWantedAt: now }).where(eq(leads.id, lead.id)); + continue; + } + if (now.getTime() - lead.galleryWantedAt.getTime() < GALLERY_WAIT_MS) continue; + } + try { + await postLead({ db, config, sender, fetchImage, now }, bot, lead, agentName, lead.commission!, lead.groupPostedCommission !== null); + failedAt.delete(lead.id); + sent++; + // Пауза между объектами: сообщения разных объектов не идут вплотную и не упираются в лимит группы. + if (pause) await sleep(pause); + } catch (error) { + failedAt.set(lead.id, now.getTime()); + // Повторим на следующем проходе. + console.error(`[group] объект ${lead.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return sent; +} diff --git a/apps/api/src/ingest/service.ts b/apps/api/src/ingest/service.ts new file mode 100644 index 0000000..3c80ba5 --- /dev/null +++ b/apps/api/src/ingest/service.ts @@ -0,0 +1,506 @@ +import { + accessState, + canWrite, + matchesFilter, + normalizePhone, + type DealType, + type MatchableListing, + type ListingFeatures, +} from '@lr/shared'; +import { and, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { blacklist, companies, filters, leads, listings, parserRuns, parserState, pipelineStatuses, seenListings, users } from '../db/schema.ts'; +import { createLeadFromListing, inDepartment, rejectRemovedListingLeads, sameDepartment } from '../leads/service.ts'; +import { LISTAM_CATEGORIES, type ListAmCard, type ListAmCategory } from '../parser/listam-source.ts'; +import { loadParserSettings, saveParserSettings } from '../parser/settings.ts'; +import type { SourcedListing } from '../parser/source.ts'; +import { enqueueText } from '../telegram/outbox.ts'; + +// Конвейер: страницы категорий list.am → свежие объявления в общую базу → фильтры компаний → лиды. + +/** Источник, читающий категории постранично (ListAmListingSource). */ +export interface PagedSource { + readonly name: string; + listPage(category: ListAmCategory, page?: number): Promise; + /** + * Дата размещения и, если объявление свежее, его детали. `publishedAt` пуст, если даты на + * странице нет. Бросает ошибку, если страницу прочитать не удалось. + */ + inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise; + /** Есть ли объявление на сайте; undefined — понять не удалось. Нужен для отметки «Снято с List.am». */ + /** Все фото объявления — для страницы клиента. */ + gallery?(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }>; + isAlive?(externalId: string): Promise; + /** Объявление целиком по номеру; null — его нет на сайте. Нужен, чтобы агент добавил объект ссылкой. */ + item?(externalId: string): Promise; + /** Ключ ридера из настроек: undefined — ключ сервера по умолчанию, null — без ключа. */ + configure?(apiKey: string | null | undefined): void; +} + +type Listing = typeof listings.$inferSelect; + +export interface IngestReport { + status: 'ok' | 'failed'; + categories: number; + foundNew: number; + /** Объявления из базы, которые подняли на list.am. */ + bumped: number; + leads: number; + errors: string[]; +} + +/** + * Обычный список list.am отсортирован по «поднятию»: всё, что оказалось наверху с прошлого прохода, + * только что опубликовали или подняли, и оно идёт в базу. Блок «Топ» ротируется, а первый проход + * застаёт объявления, появившиеся до подключения, — там в базу попадают только размещённые не раньше + * чем 3 дня назад (по дате «Размещено» на странице объявления). + */ +export const FRESH_DAYS = 3; +/** + * ID объявлений list.am растут по порядку, примерно на 4 000 в день (замер 23.09–08.10.2026). + * Если ID отстаёт от самого большого из виденных больше чем на 20 000 (~5 дней), объявление + * заведомо старше 3 дней — страницу объявления ради даты можно не читать. + */ +const IDS_PER_DAY = 4_000; +const oldIdGap = (freshDays: number) => (freshDays + 2) * IDS_PER_DAY; +/** У list.am две нумерации: старая (до 900 млн, закончилась в сентябре 2026) и новая «995…». */ +const idRange = (n: number) => (n >= 900_000_000 ? 1 : 0); +/** Сколько раз подряд страница объявления может не отдать дату, прежде чем судить по ID. */ +const DATE_ATTEMPTS = 3; +const dateMisses = new Map(); + +/** Дата известна с точностью до дня (начало дня по Еревану), поэтому сравниваем по дням. */ +export const isFresh = (publishedAt: Date, now = Date.now(), freshDays = FRESH_DAYS) => + now - publishedAt.getTime() < (freshDays + 1) * 86_400_000; + +/** + * Какие объявления подняли с прошлого прохода. Обычные объявления в списке отсортированы по + * «поднятию»: те, что не поднимали, стоят в прежнем порядке. Объявление, оказавшееся выше того, + * что на прошлом проходе стояло выше него, подняли. Без прошлого порядка понять это нельзя. + */ +export function bumpedIds(order: string[], previous: string[]): Set { + const before = new Map(previous.map((id, i) => [id, i])); + const bumped = new Set(); + if (!before.size) return bumped; + let below = Infinity; + for (let i = order.length - 1; i >= 0; i--) { + const at = before.get(order[i]!) ?? Infinity; + if (at > below) bumped.add(order[i]!); + below = Math.min(below, at); + } + return bumped; +} + +/** Сколько объявлений из начала списка категории помнится до следующего прохода. */ +const ORDER_KEEP = 200; + +interface ActiveFilter { + filter: typeof filters.$inferSelect; + company: typeof companies.$inferSelect; +} + +/** Включённые фильтры компаний с действующим доступом: только по ним парсер что-то ищет. */ +async function activeFilters(db: Db): Promise { + const rows = await db + .select({ filter: filters, company: companies }) + .from(filters) + .innerJoin(companies, eq(companies.id, filters.companyId)) + .where(eq(filters.enabled, true)); + return rows.filter((r) => canWrite(accessState(r.company))); +} + +const toRow = (item: SourcedListing) => ({ + externalId: item.externalId, + url: item.sourceUrl, + title: item.title, + description: item.description ?? null, + dealType: item.dealType ?? null, + propertyType: item.propertyType ?? null, + region: item.region ?? null, + district: item.district ?? null, + address: item.address ?? null, + price: item.price ?? null, + currency: item.currency, + rooms: item.rooms ?? null, + area: item.area ?? null, + floor: item.floor ?? null, + floorsTotal: item.floorsTotal ?? null, + photos: item.photoUrls, + authorName: item.ownerName ?? null, + phone: normalizePhone(item.phone), + isAgency: item.isAgency, + publishedAt: item.publishedAt ?? null, + features: item.features ?? null, +}); + +/** + * Объявление, которое агент добавил ссылкой, а в общей базе его нет: страница читается с list.am + * и объявление сохраняется в общую базу. Возвращает null, если объявления на сайте нет. + */ +export async function importListing(db: Db, source: PagedSource, externalId: string): Promise { + const settings = await loadParserSettings(db); + source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null); + const item = await source.item!(externalId); + if (!item) return null; + await db.insert(listings).values(toRow(item)).onConflictDoNothing(); + const [row] = await db.select().from(listings).where(eq(listings.externalId, externalId)); + return row ?? null; +} + +/** + * Прошлый лид компании по тому же объекту, опубликованному заново под другим номером: + * совпали адрес и телефон автора. Если телефон неизвестен — адрес и характеристики, и прошлое + * объявление при этом снято с сайта. + */ +async function similarPrevious(db: Db, companyId: number, departmentId: number | null, listing: Listing): Promise { + if (!listing.address?.trim()) return null; + const sameSpecs = sql`(${leads.propertyType} is not distinct from ${listing.propertyType} and ${leads.dealType} is not distinct from ${listing.dealType} + and ${leads.rooms} is not distinct from ${listing.rooms} and ${leads.area} is not distinct from ${listing.area} + and ${leads.floor} is not distinct from ${listing.floor} and not ${listings.active})`; + const [row] = await db + .select({ id: leads.id }) + .from(leads) + .innerJoin(listings, eq(listings.id, leads.listingId)) + .where( + and( + eq(leads.companyId, companyId), + inDepartment(leads.departmentId, departmentId), + ne(leads.listingId, listing.id), + sql`lower(trim(${leads.address})) = lower(trim(${listing.address}))`, + listing.phone + ? sql`(${leads.phone} = ${listing.phone} or (${leads.phone} is null and ${sameSpecs}))` + : sameSpecs, + ), + ) + .orderBy(desc(leads.id)) + .limit(1); + return row?.id ?? null; +} + +/** + * Сопоставляет объявление с фильтрами и создаёт лиды. У каждого отдела компании свой лид: объект + * приходит в отдел, чей фильтр сработал (общий фильтр компании — в «без отдела», личный фильтр — в отдел + * владельца). Уведомление получают сотрудники этого отдела, если сработал общий фильтр или фильтр отдела, + * и владелец сработавшего личного фильтра. + * `republished` — объявление сняли и опубликовали снова под тем же номером: компании, у которой + * прошлый лид закрыт сделкой или отказом, объект приходит новым лидом со ссылкой на прошлый. + */ +export async function distribute(db: Db, listing: Listing, active: ActiveFilter[], notify = true, republished = false): Promise { + const matchable: MatchableListing = { ...listing, dealType: listing.dealType as DealType | null, currency: listing.currency as MatchableListing['currency'] }; + const byCompany = new Map(); + for (const a of active) byCompany.set(a.company.id, [...(byCompany.get(a.company.id) ?? []), a]); + + let created = 0; + for (const [companyId, list] of byCompany) { + const company = list[0]!.company; + let blacklisted = false; + let activeForPhone = 0; + if (listing.phone) { + const [b] = await db + .select({ id: blacklist.id }) + .from(blacklist) + .where(and(eq(blacklist.companyId, companyId), eq(blacklist.phone, listing.phone))); + blacklisted = Boolean(b); + const [n] = await db + .select({ n: sql`count(*)::int` }) + .from(listings) + .where(and(eq(listings.phone, listing.phone), eq(listings.active, true))); + activeForPhone = n?.n ?? 0; + } + const ctx = { rules: company.settings.agentRules, blacklisted, activeListingsForPhone: activeForPhone }; + const matched = list.filter((a) => matchesFilter(matchable, a.filter.params, ctx)); + if (!matched.length) continue; + + const members = await db + .select() + .from(users) + .where(and(eq(users.companyId, companyId), isNull(users.removedAt))); + const departmentOf = new Map(members.map((u) => [u.id, u.departmentId ?? null])); + /** Отдел → сработал общий фильтр (компании или отдела) и чьи личные фильтры сработали. */ + const targets = new Map }>(); + for (const { filter } of matched) { + // Личный фильтр удалённого сотрудника объектов не приносит. + if (filter.ownerUserId !== null && !departmentOf.has(filter.ownerUserId)) continue; + const department = filter.ownerUserId === null ? (filter.departmentId ?? null) : departmentOf.get(filter.ownerUserId)!; + const target = targets.get(department) ?? { common: false, personal: new Set() }; + if (filter.ownerUserId === null) target.common = true; + else target.personal.add(filter.ownerUserId); + targets.set(department, target); + } + + for (const [departmentId, { common, personal }] of targets) { + const recipients = notify + ? members.filter((u) => (common && sameDepartment(u.departmentId, departmentId)) || personal.has(u.id)) + : []; + const [last] = await db + .select({ id: leads.id, code: pipelineStatuses.code }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .where(and(eq(leads.companyId, companyId), inDepartment(leads.departmentId, departmentId), eq(leads.listingId, listing.id))) + .orderBy(desc(leads.id)) + .limit(1); + // По этому объявлению у отдела уже есть лид. Новый нужен, только если объявление + // опубликовали заново, а прошлый лид закрыт. + if (last && !(republished && (last.code === 'deal' || last.code === 'rejected'))) continue; + const previousLeadId = last?.id ?? (await similarPrevious(db, companyId, departmentId, listing)); + + const lead = await db.transaction((tx) => createLeadFromListing(tx, companyId, listing, recipients, previousLeadId, departmentId)); + if (lead) created++; + } + } + return created; +} + +/** Сколько объявлений из базы просматривается при создании или изменении фильтра. */ +const BACKFILL_LIMIT = 2000; + +/** + * Новый или изменённый фильтр сразу получает подходящие объявления из общей базы: + * они появляются в пуле компании без уведомлений в Telegram (объявления не новые). + * Возвращает число созданных лидов. + */ +export async function backfillFilter(db: Db, filterId: number, now = Date.now()): Promise { + const [row] = await db + .select({ filter: filters, company: companies }) + .from(filters) + .innerJoin(companies, eq(companies.id, filters.companyId)) + .where(eq(filters.id, filterId)); + if (!row || !row.filter.enabled || !canWrite(accessState(row.company))) return 0; + // В базе лежат объявления не старше 3 дней на момент находки или поднятия; со временем они стареют, + // поэтому свежесть проверяется ещё раз. + const { freshDays } = await loadParserSettings(db); + const since = new Date(now - (freshDays + 1) * 86_400_000); + const candidates = await db + .select() + .from(listings) + .where(and(eq(listings.active, true), sql`coalesce(${listings.bumpedAt}, ${listings.publishedAt}, ${listings.createdAt}) >= ${since.toISOString()}::timestamptz`)) + .orderBy(desc(listings.id)) + .limit(BACKFILL_LIMIT); + let created = 0; + for (const listing of candidates) created += await distribute(db, listing, [row], false); + return created; +} + +export interface IngestDeps { + db: Db; + source: PagedSource; + log?: (message: string) => void; +} + +/** + * Один проход парсера. Читаются все категории недвижимости list.am: свежие объявления + * складываются в общую базу сервиса независимо от того, есть ли под них фильтр, и уже из базы + * расходятся клиентам по фильтрам. + */ +export async function runIngest({ db, source, log = () => {} }: IngestDeps): Promise { + const active = await activeFilters(db); + const settings = await loadParserSettings(db); + source.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null); + const categories = LISTAM_CATEGORIES.filter((c) => settings.categories.includes(c.id)); + const isRecent = (d: Date, at: number) => isFresh(d, at, settings.freshDays); + const deep = settings.deepPass; + if (deep) log(`Глубокий проход: до ${settings.maxPages} стр., свежесть ${settings.freshDays} дн.`); + const [run] = await db.insert(parserRuns).values({}).returning(); + const report: IngestReport = { status: 'ok', categories: categories.length, foundNew: 0, bumped: 0, leads: 0, errors: [] }; + const state = new Map((await db.select().from(parserState)).map((s) => [s.categoryId, s])); + const [max] = await db + .select({ + a: sql`max(external_id::bigint) filter (where external_id::bigint < 900000000)`, + b: sql`max(external_id::bigint) filter (where external_id::bigint >= 900000000)`, + }) + .from(listings) + .where(sql`external_id ~ '^[0-9]{1,15}$'`); + const highest = [Number(max?.a ?? 0), Number(max?.b ?? 0)]; + const now = Date.now(); + const skip = (externalId: string) => db.insert(seenListings).values({ externalId }).onConflictDoNothing(); + + // Страницы категорий запрашиваются сразу все: источник сам выдерживает темп запросов. + const results = await Promise.allSettled( + categories.map(async (category) => { + // Первый проход по категории наполняет базу без лидов: эти объявления появились + // на сайте до подключения, клиентам о них уведомления не нужны. + const baseline = !state.has(category.id); + const previous = state.get(category.id)?.topIds ?? []; + /** Обычные объявления этого прохода в порядке списка. */ + const order: string[] = []; + let read = false; + for (let page = 1; page <= (baseline ? 1 : settings.maxPages); page++) { + const cards = await source.listPage(category, page); + if (!cards.length) break; + read = true; + const ids = cards.map((c) => c.externalId); + const inBase = new Map( + (await db.select().from(listings).where(inArray(listings.externalId, ids))).map((r) => [r.externalId, r] as const), + ); + const seen = new Set( + (await db.select({ id: seenListings.externalId }).from(seenListings).where(inArray(seenListings.externalId, ids))).map((r) => r.id), + ); + // Снятое объявление снова в списке — его опубликовали заново. + const back = await db + .update(listings) + .set({ active: true, lastCheckedAt: new Date() }) + .where(and(inArray(listings.externalId, ids), eq(listings.active, false))) + .returning(); + for (const row of back) if (!baseline) report.leads += await distribute(db, row, active, true, true); + for (const n of cards.map((c) => Number(c.externalId)).filter(Number.isFinite)) { + if (n > highest[idRange(n)]!) highest[idRange(n)] = n; + } + const regular = cards.filter((c) => !c.promoted); + order.push(...regular.map((c) => c.externalId)); + const bumped = baseline ? new Set() : bumpedIds(order, previous); + /** Обычные объявления страницы, которые новые для списка: опубликованы или подняты с прошлого прохода. */ + let moved = 0; + + for (const card of cards) { + const isBumped = !card.promoted && bumped.has(card.externalId); + const row = inBase.get(card.externalId); + if (row) { + if (!isBumped) continue; + // Объявление из базы подняли: объект встаёт наверх в базе и у компаний, где по нему есть лид. + moved++; + report.bumped++; + // Время поднятия ставит база — по тем же часам, что и время создания лидов: по ним сортируется список. + const [lifted] = await db.update(listings).set({ bumpedAt: sql`now()` }).where(eq(listings.id, row.id)).returning(); + await db.update(leads).set({ bumpedAt: lifted!.bumpedAt }).where(eq(leads.listingId, row.id)); + // Компании, у которых лида по нему нет, а фильтр подходит, получают его как новый. + report.leads += await distribute(db, lifted!, active); + continue; + } + if (seen.has(card.externalId)) { + if (!isBumped) continue; + // Старое объявление, которое раньше пропустили, подняли — теперь оно актуально. + await db.delete(seenListings).where(eq(seenListings.externalId, card.externalId)); + } + if (!card.promoted) moved++; + // Новое для списка обычное объявление после первого прохода опубликовали или подняли только что: + // оно идёт в базу, как бы давно его ни разместили. Блок «Топ» ротируется — там судим по дате. + // В глубоком проходе объявления из глубины списка — не «только что опубликованные»: судим по дате. + const anyAge = !baseline && !card.promoted && !deep; + const id = Number(card.externalId); + if (!anyAge && Number.isFinite(id) && id < highest[idRange(id)]! - oldIdGap(settings.freshDays)) { + // Заведомо старое объявление, которое «подняли» в списке. + await skip(card.externalId); + continue; + } + let full: SourcedListing; + let problem = 'на странице нет даты размещения'; + try { + full = await source.inspect(card, (d) => anyAge || isRecent(d, now)); + } catch (error) { + full = card; + problem = error instanceof Error ? error.message : String(error); + } + if (!full.publishedAt) { + // Страницу не удалось прочитать или она пришла без даты. Пробуем ещё на следующих + // проходах, затем берём данные из списка и судим по ID: он уже прошёл проверку + // «не старше ~5 дней». + const misses = (dateMisses.get(card.externalId) ?? 0) + 1; + if (misses < DATE_ATTEMPTS) { + dateMisses.set(card.externalId, misses); + report.errors.push(`${card.sourceUrl}: ${problem}, попытка ${misses}`); + continue; + } + } else if (!anyAge && !isRecent(full.publishedAt, now)) { + await skip(card.externalId); + continue; + } + dateMisses.delete(card.externalId); + const old = full.publishedAt && !isRecent(full.publishedAt, now); + const [created] = await db.insert(listings).values({ ...toRow(full), bumpedAt: old ? sql`now()` : null }).onConflictDoNothing().returning(); + if (!created) continue; + report.foundNew++; + if (!baseline) report.leads += await distribute(db, created, active); + } + // На странице остались объявления, стоящие на прежних местах, — дальше всё уже было. + if (!regular.length || (!deep && moved < regular.length)) break; + } + if (read) { + const topIds = order.slice(0, ORDER_KEEP); + await db + .insert(parserState) + .values({ categoryId: category.id, topIds }) + .onConflictDoUpdate({ target: parserState.categoryId, set: { topIds } }); + } + }), + ); + const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + for (const f of failures) report.errors.push(f.reason instanceof Error ? f.reason.message : String(f.reason)); + if (categories.length && failures.length === categories.length) report.status = 'failed'; + + await db + .update(parserRuns) + .set({ + finishedAt: new Date(), + status: report.status, + foundNew: report.foundNew, + errors: report.errors.length ? report.errors.slice(0, 50).join('\n') : null, + }) + .where(eq(parserRuns.id, run!.id)); + log(`Парсер: в базу ${report.foundNew}, лидов ${report.leads}, ошибок ${report.errors.length}`); + + if (report.status === 'failed') await alertSuperadmins(db, report.errors[0] ?? 'источник недоступен'); + else if (deep) await saveParserSettings(db, { deepPass: false }); + return report; +} + +/** Алерт суперадминам в Telegram при сбое парсера — не чаще раза в час. */ +async function alertSuperadmins(db: Db, error: string) { + const [recent] = await db + .select({ n: sql`count(*)::int` }) + .from(parserRuns) + .where(and(eq(parserRuns.status, 'failed'), gte(parserRuns.startedAt, new Date(Date.now() - 3_600_000)))); + if ((recent?.n ?? 0) > 1) return; + const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin')); + await enqueueText(db, admins.map((a) => a.id), 'parserFailed', [error.slice(0, 300)]); +} + + +/** Через сколько часов после находки объявление проверяется на list.am первый раз. */ +const FIRST_CHECK_HOURS = 4; + +/** + * Проверка объектов из CRM на list.am: снятое объявление получает отметку и дату снятия. + * За запуск проверяется небольшая пачка — запросы идут в общей очереди с парсером; каждое + * объявление проверяется раз в сутки, первый раз — через FIRST_CHECK_HOURS после находки. + * Сначала идут объекты, которые кто-то брал в работу. + */ +export async function runRemovalCheck({ db, source, now = new Date() }: IngestDeps & { now?: Date }) { + const report = { checked: 0, removed: 0 }; + const settings = await loadParserSettings(db); + if (!source.isAlive || !settings.removalCheck) return report; + const RECHECK_HOURS = settings.recheckHours; + const limit = settings.removalBatch; + const due = new Date(now.getTime() - RECHECK_HOURS * 3_600_000).toISOString(); + // Свежие объявления чаще всего снимают в первые сутки, поэтому первая проверка — раньше повторных. + const firstDue = new Date(now.getTime() - Math.min(FIRST_CHECK_HOURS, RECHECK_HOURS) * 3_600_000).toISOString(); + const worked = sql`exists (select 1 from leads l where l.listing_id = "listings"."id" and (l.assignee_id is not null or l.taken_at is not null))`; + const batch = await db + .select({ id: listings.id, externalId: listings.externalId }) + .from(listings) + .where( + and( + eq(listings.active, true), + sql`exists (select 1 from leads l where l.listing_id = "listings"."id")`, + sql`(${listings.lastCheckedAt} <= ${due}::timestamptz or (${listings.lastCheckedAt} is null and ${listings.createdAt} <= ${firstDue}::timestamptz))`, + ), + ) + // Из ни разу не проверенных первыми идут самые новые: их чаще всего уже сняли, пока очередь доходит. + .orderBy(desc(worked), sql`${listings.lastCheckedAt} nulls first`, desc(listings.id)) + .limit(limit); + for (const row of batch) { + const alive = await source.isAlive(row.externalId).catch(() => undefined); + report.checked++; + if (alive === false) { + report.removed++; + await db.transaction(async (tx) => { + await tx.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(eq(listings.id, row.id)); + await rejectRemovedListingLeads(tx, row.id); + }); + } else { + // Не удалось понять — повторим через час, а не через сутки. + const checkedAt = alive ? now : new Date(now.getTime() - (RECHECK_HOURS - 1) * 3_600_000); + await db.update(listings).set({ lastCheckedAt: checkedAt }).where(eq(listings.id, row.id)); + } + } + return report; +} diff --git a/apps/api/src/ingest/watchdog.ts b/apps/api/src/ingest/watchdog.ts new file mode 100644 index 0000000..adcf637 --- /dev/null +++ b/apps/api/src/ingest/watchdog.ts @@ -0,0 +1,59 @@ +import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { parserRuns, settings, users } from '../db/schema.ts'; +import { loadParserSettings } from '../parser/settings.ts'; +import { enqueueRaw } from '../telegram/outbox.ts'; + +// Сторож парсера: если парсер выключен или давно не запускался, суперадминам приходит сообщение +// в Telegram; пока проблема не ушла — напоминание раз в несколько часов, после — «снова работает». +// 20.10 парсер простоял почти двое суток выключенным, и это заметили только по пропавшему объекту. + +/** Сколько минут без нового прохода считается «стоит». Глубокий проход идёт ~10 минут. */ +export const STALL_MINUTES = 20; +const REMIND_HOURS = 6; + +export interface WatchdogState { + /** Когда отправлено последнее сообщение о простое; null — парсер в порядке. */ + alertedAt: Date | null; +} + +const ago = (from: Date, now: Date) => { + const min = Math.round((now.getTime() - from.getTime()) / 60_000); + return min < 120 ? `${min} мин` : `${Math.round(min / 60)} ч`; +}; + +/** Что не так с парсером, или null, если всё в порядке. */ +export async function parserProblem(db: Db, now = new Date()): Promise { + const parser = await loadParserSettings(db); + const stall = STALL_MINUTES * 60_000; + if (!parser.enabled) { + const [row] = await db.select({ at: settings.updatedAt }).from(settings).where(eq(settings.key, 'parser')); + const since = row?.at ?? now; + // Только что выключили руками — даём время включить обратно. + return now.getTime() - since.getTime() >= stall ? `выключен в настройках (уже ${ago(since, now)})` : null; + } + const [last] = await db.select({ at: parserRuns.startedAt }).from(parserRuns).orderBy(desc(parserRuns.id)).limit(1); + if (!last) return null; + return now.getTime() - last.at.getTime() >= stall ? `не запускался ${ago(last.at, now)}` : null; +} + +/** Одна проверка: шлёт сообщение о простое (с напоминаниями) и о восстановлении. */ +export async function runParserWatchdog(db: Db, state: WatchdogState, now = new Date()): Promise { + const problem = await parserProblem(db, now); + const remind = state.alertedAt && now.getTime() - state.alertedAt.getTime() >= REMIND_HOURS * 3_600_000; + if (problem && (!state.alertedAt || remind)) { + await notify(db, `⚠️ Парсер стоит: ${problem}. Новые объекты не приходят.\nВключить — Админка → Парсер.`); + state.alertedAt = now; + } else if (!problem && state.alertedAt) { + await notify(db, '✅ Парсер снова работает.'); + state.alertedAt = null; + } +} + +async function notify(db: Db, text: string) { + const admins = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt))); + await enqueueRaw(db, admins.map((a) => a.id), text); +} diff --git a/apps/api/src/intake/service.ts b/apps/api/src/intake/service.ts new file mode 100644 index 0000000..aa57be3 --- /dev/null +++ b/apps/api/src/intake/service.ts @@ -0,0 +1,412 @@ +import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type Household, type RentTerm } from '@lr/shared'; +import { and, desc, eq, isNotNull, sql } from 'drizzle-orm'; +import { newToken } from '../auth/sessions.ts'; +import type { Db } from '../db/client.ts'; +import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { statusByCode } from '../pipelines/service.ts'; +import type { Keyboard, TelegramApi, TgUpdate } from '../telegram/client.ts'; +import { enqueueRaw } from '../telegram/outbox.ts'; +import { esc } from '../telegram/texts.ts'; +import { intakeTexts, LANGUAGE_BUTTONS, LANGUAGE_QUESTION } from './texts.ts'; + +// Клиентский бот: соискатель переходит по ссылке агента и отвечает на вопросы анкеты. +// По готовой анкете в CRM появляется клиент, закреплённый за автором ссылки, а автору в основной +// бот приходит сводка с вопросом «Показать подходящие объекты?». + +type User = typeof users.$inferSelect; +type Session = typeof intakeSessions.$inferSelect; + +/** Ссылка агента на анкету; одна на агента, создаётся при первом запросе. null — клиентский бот не настроен. */ +export async function intakeLink(db: Db, config: Config, user: User): Promise { + if (!config.clientBotToken || !config.clientBotUsername || !user.companyId) return null; + const [link] = await db + .insert(intakeLinks) + .values({ token: newToken().slice(0, 24), companyId: user.companyId, userId: user.id }) + .onConflictDoUpdate({ target: intakeLinks.userId, set: { companyId: user.companyId } }) + .returning(); + return `https://t.me/${config.clientBotUsername}?start=${link!.token}`; +} + +/** Скачивает фото, присланное в клиентский бот. null — не получилось или файл слишком большой. */ +export type FileDownloader = (fileId: string) => Promise<{ data: Buffer; type: string } | null>; + +export const telegramDownloader = (token: string): FileDownloader => async (fileId) => { + const info = (await (await fetch(`https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as { + ok: boolean; + result?: { file_path?: string; file_size?: number }; + }; + if (!info.ok || !info.result?.file_path || (info.result.file_size ?? 0) > 5 * 1024 * 1024) return null; + const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.result.file_path}`); + if (!res.ok) return null; + return { data: Buffer.from(await res.arrayBuffer()), type: 'image/jpeg' }; +}; + +export interface IntakeDeps { + db: Db; + /** Клиентский бот. */ + tg: TelegramApi; + config: Config; + download?: FileDownloader; +} + +interface Answers { + deal?: 'rent' | 'sale'; + type?: string; + place?: string; + budget?: { amount: number; currency: 'USD' | 'AMD' | 'RUB' | 'EUR' } | null; + rooms?: string; + /** Срок аренды: short | mid | long. */ + term?: RentTerm; + who?: 'alone' | 'friend' | 'couple'; + children?: number; + childrenAge?: string; + pets?: number; + petsAge?: string; + name?: string; + phone?: string | null; +} + +const STEPS = ['deal', 'type', 'place', 'budget', 'rooms', 'term', 'who', 'children', 'childrenAge', 'pets', 'petsAge', 'name', 'phone'] as const; +type Step = (typeof STEPS)[number]; + +/** Следующий вопрос: жильцов спрашиваем только у арендаторов, возраст — только если дети или животные есть. */ +function nextStep(after: Step | null, a: Answers): Step | 'done' { + for (const step of STEPS.slice(after ? STEPS.indexOf(after) + 1 : 0)) { + if (a.deal === 'sale' && ['term', 'who', 'children', 'childrenAge', 'pets', 'petsAge'].includes(step)) continue; + if (step === 'childrenAge' && !a.children) continue; + if (step === 'petsAge' && !a.pets) continue; + return step; + } + return 'done'; +} + +/** «600 000 драм», «1500$», «2000 usd» → сумма и валюта. */ +export function parseBudget(text: string, deal: 'rent' | 'sale' = 'rent'): Answers['budget'] | undefined { + const digits = text.replace(/[\s.,'’]/g, '').match(/\d{2,12}/)?.[0]; + if (!digits) return undefined; + const amount = Number(digits); + const t = text.toLowerCase(); + const currency = /usd|\$|долл|դոլար/.test(t) + ? 'USD' + : /amd|драм|֏|դրամ/.test(t) + ? 'AMD' + : /rub|руб|₽/.test(t) + ? 'RUB' + : /eur|евро|€/.test(t) + ? 'EUR' + : // Валюта не названа: по величине суммы. Аренда от 20 000 и покупка от 5 млн — это драмы. + amount >= (deal === 'rent' ? 20_000 : 5_000_000) + ? 'AMD' + : 'USD'; + return { amount, currency }; +} + +function toRequest(a: Answers): Partial { + const rooms = a.rooms === '4' ? [4, 5, 6] : a.rooms && a.rooms !== 'any' ? [Number(a.rooms)] : []; + const district = a.place && a.place !== 'any' && a.place !== 'yerevan' ? a.place : null; + return { + propertyTypes: a.type && a.type !== 'any' ? [a.type as 'apartment'] : [], + ...(a.place && a.place !== 'any' ? { region: 'Ереван' } : {}), + districts: district ? [district] : [], + ...(a.budget ? { priceTo: a.budget.amount, priceCurrency: a.budget.currency } : {}), + rooms, + }; +} + +function toHousehold(a: Answers): Household | null { + if (a.deal !== 'rent') return null; + const h: Household = { + ...(a.who ? { who: a.who } : {}), + ...(a.children ? { children: { count: a.children, age: a.childrenAge ?? '' } } : {}), + ...(a.pets ? { pets: { count: a.pets, age: a.petsAge ?? '' } } : {}), + }; + return Object.keys(h).length ? h : null; +} + +const btn = (text: string, step: Step, value: string) => ({ text, callback: `q:${step}:${value}` }); + +function question(step: Step, s: Session): { text: string; keyboard?: Keyboard } { + const t = intakeTexts(s.lang); + switch (step) { + case 'deal': + return { text: t.deal, keyboard: [[btn(t.rent, step, 'rent'), btn(t.sale, step, 'sale')]] }; + case 'type': + return { text: t.type, keyboard: [[btn(t.apartment, step, 'apartment'), btn(t.house, step, 'house')], [btn(t.commercial, step, 'commercial'), btn(t.any, step, 'any')]] }; + case 'place': { + const rows: Keyboard = [[btn(t.yerevan, step, 'yerevan'), btn(t.anywhere, step, 'any')]]; + for (let i = 0; i < YEREVAN_DISTRICTS.length; i += 2) { + rows.push(YEREVAN_DISTRICTS.slice(i, i + 2).map((d) => btn(d, step, d))); + } + return { text: t.place, keyboard: rows }; + } + case 'budget': + return { text: t.budget, keyboard: [[btn(t.skip, step, 'skip')]] }; + case 'rooms': + return { text: t.rooms, keyboard: [[btn('1', step, '1'), btn('2', step, '2'), btn('3', step, '3')], [btn(t.rooms4, step, '4'), btn(t.any, step, 'any')]] }; + case 'term': + return { text: t.term, keyboard: [[btn(t.termShort, step, 'short'), btn(t.termMid, step, 'mid')], [btn(t.termLong, step, 'long')]] }; + case 'who': + return { text: t.who, keyboard: [[btn(t.alone, step, 'alone'), btn(t.friend, step, 'friend')], [btn(t.couple, step, 'couple')]] }; + case 'children': + return { text: t.children, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn('2', step, '2'), btn(t.more3, step, '3')]] }; + case 'childrenAge': + return { text: t.childrenAge }; + case 'pets': + return { text: t.pets, keyboard: [[btn(t.none, step, '0'), btn('1', step, '1'), btn(t.more2, step, '2')]] }; + case 'petsAge': + return { text: t.petsAge }; + case 'name': + return { text: t.name, keyboard: s.tgName ? [[btn(t.keepName(s.tgName.slice(0, 30)), step, 'keep')]] : undefined }; + case 'phone': + return { text: t.phone, keyboard: [[btn(t.skip, step, 'skip')]] }; + } +} + +/** Ответ на текущий вопрос. undefined — ответ не подошёл (вторым значением — что сказать). */ +function accept(step: Step, s: Session, value: string, viaButton: boolean): { patch: Partial } | { error: string } { + const t = intakeTexts(s.lang); + const a = s.answers as Answers; + const buttonOnly = { error: t.useButtons }; + switch (step) { + case 'deal': + return viaButton && (value === 'rent' || value === 'sale') ? { patch: { deal: value } } : buttonOnly; + case 'type': + return viaButton ? { patch: { type: value } } : buttonOnly; + case 'place': + return viaButton ? { patch: { place: value } } : buttonOnly; + case 'budget': { + if (viaButton) return { patch: { budget: null } }; + const budget = parseBudget(value, a.deal); + return budget ? { patch: { budget } } : { error: t.badBudget }; + } + case 'rooms': + return viaButton ? { patch: { rooms: value } } : buttonOnly; + case 'term': + return viaButton && (RENT_TERMS as readonly string[]).includes(value) ? { patch: { term: value as RentTerm } } : buttonOnly; + case 'who': + return viaButton ? { patch: { who: value as 'alone' } } : buttonOnly; + case 'children': + return viaButton ? { patch: { children: Number(value) || 0 } } : buttonOnly; + case 'childrenAge': + return { patch: { childrenAge: value.slice(0, 100) } }; + case 'pets': + return viaButton ? { patch: { pets: Number(value) || 0 } } : buttonOnly; + case 'petsAge': + return { patch: { petsAge: value.slice(0, 100) } }; + case 'name': + return { patch: { name: (viaButton ? (s.tgName ?? 'Telegram') : value).slice(0, 120) } }; + case 'phone': { + if (viaButton) return { patch: { phone: null } }; + return value.replace(/\D/g, '').length >= 6 ? { patch: { phone: value.slice(0, 40) } } : { error: t.badPhone }; + } + } +} + +/** Анкета заполнена: клиент в CRM и сводка автору ссылки в основной бот. */ +async function complete(db: Db, s: Session, a: Answers) { + return db.transaction(async (tx) => { + const [link] = await tx.select().from(intakeLinks).where(eq(intakeLinks.token, s.linkToken)); + const [owner] = await tx.select().from(users).where(eq(users.id, link!.userId)); + // Тот же человек уже заполнял анкету этого агента — обновляем его карточку, а не плодим вторую. + const [earlier] = await tx + .select({ clientId: intakeSessions.clientId }) + .from(intakeSessions) + .where(and(eq(intakeSessions.chatId, s.chatId), eq(intakeSessions.linkToken, s.linkToken), isNotNull(intakeSessions.clientId))) + .orderBy(desc(intakeSessions.id)) + .limit(1); + const fields = { + name: a.name ?? s.tgName ?? 'Telegram', + phone: a.phone ? (normalizePhone(a.phone) ?? a.phone) : null, + // Telegram — из чата анкеты: связаться с клиентом можно и без телефона. + telegram: s.tgUsername ? `@${s.tgUsername}` : null, + rentTerm: a.deal === 'sale' ? null : (a.term ?? null), + kind: a.deal === 'sale' ? 'buyer' : 'tenant', + request: toRequest(a), + household: toHousehold(a), + }; + let clientId = earlier?.clientId ?? null; + if (clientId) { + await tx.update(clients).set(fields).where(eq(clients.id, clientId)); + await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'edited' }); + } else { + const fresh = await statusByCode(tx, link!.companyId, 'new', 'client'); + const [client] = await tx + .insert(clients) + .values({ + ...fields, companyId: link!.companyId, assigneeId: link!.userId, statusId: fresh.id, source: 'Анкета в Telegram', + // Клиент попадает в отдел агента, чья это ссылка. + departmentId: owner?.departmentId ?? null, + }) + .returning(); + clientId = client!.id; + await tx.insert(events).values({ companyId: link!.companyId, clientId, userId: null, type: 'created' }); + } + await tx.update(intakeSessions).set({ step: 'done', answers: a as Record, clientId, completedAt: new Date() }).where(eq(intakeSessions.id, s.id)); + await tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, link!.companyId)); + // Сводка автору ссылки с вопросом, показать ли клиенту подходящие объекты. + await tx.insert(outbox).values({ + kind: 'text', + userId: link!.userId, + payload: { key: 'intakeDone', args: [fields.name, fields.kind, clientId], buttons: [{ key: 'intakeMatch', callback: `match:${clientId}` }, { key: 'intakeLater', callback: `nomatch:${clientId}` }] }, + }); + return { clientId, owner: owner! }; + }); +} + +/** + * Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю + * карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот. + */ +async function saveReply(db: Db, clientId: number, reply: string): Promise { + return db.transaction(async (tx) => { + const [client] = await tx.select().from(clients).where(eq(clients.id, clientId)); + if (!client) return false; + const message = reply.slice(0, 1000); + const phone = message.replace(/\D/g, '').length >= 6 ? normalizePhone(message) : null; + const phoneSaved = Boolean(phone && !client.phone); + if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId)); + await tx.insert(events).values({ companyId: client.companyId, clientId, userId: null, type: 'comment', text: `Клиент написал в бот анкеты: ${message}` }); + const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), sql`${users.removedAt} is null`)); + const note = [`💬 ${client.name} (клиент #${clientId}) написал(а) в бот анкеты:`, message, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n'); + await enqueueRaw(tx, [...new Set([client.assigneeId, ...admins.map((a) => a.id)])], note); + return true; + }); +} + +/** Обрабатывает одно обновление клиентского бота. */ +export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, update: TgUpdate): Promise { + const from = update.message?.from ?? update.callback_query?.from; + if (!from) return; + const chatId = update.message?.chat.id ?? update.callback_query?.message?.chat.id ?? from.id; + if (chatId !== from.id) return; + const lang = from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru'; + const t = intakeTexts(lang); + const text = update.message?.text?.trim() ?? ''; + const ask = async (s: Session, step: Step) => { + const q = question(step, s); + await tg.sendMessage(chatId, esc(q.text), q.keyboard); + }; + const askLanguage = () => + tg.sendMessage(chatId, LANGUAGE_QUESTION, [LANGUAGE_BUTTONS.map((b) => ({ text: b.text, callback: `q:lang:${b.lang}` }))]); + + // Переход по ссылке агента: начинаем анкету заново. + const start = /^\/start(?:\s+(\S+))?$/.exec(text); + if (start) { + if (!start[1]) return void (await tg.sendMessage(chatId, t.noLink)); + const [row] = await db + .select({ link: intakeLinks, agent: users.name, company: companies.name }) + .from(intakeLinks) + .innerJoin(users, eq(users.id, intakeLinks.userId)) + .innerJoin(companies, eq(companies.id, intakeLinks.companyId)) + .where(and(eq(intakeLinks.token, start[1]), sql`${users.removedAt} is null`)); + if (!row) return void (await tg.sendMessage(chatId, t.badLink)); + const [session] = await db + .insert(intakeSessions) + .values({ + linkToken: row.link.token, + chatId, + tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null, + tgUsername: from.username ?? null, + lang, + // Первый вопрос — язык: по настройке Telegram его не угадать надёжно. + step: 'lang', + }) + .returning(); + void session; + return void (await askLanguage()); + } + + const [session] = await db.select().from(intakeSessions).where(eq(intakeSessions.chatId, chatId)).orderBy(desc(intakeSessions.id)).limit(1); + if (!session) { + if (update.callback_query) await tg.answerCallback(update.callback_query.id); + return void (await tg.sendMessage(chatId, t.noLink)); + } + // Выбор языка: первым шагом анкеты или позже командой /lang — тогда анкета продолжается с того же вопроса. + if (text === '/lang') return void (await askLanguage()); + const picked = /^q:lang:(ru|hy|en)$/.exec(update.callback_query?.data ?? '')?.[1]; + if (picked) { + await tg.answerCallback(update.callback_query!.id); + const first = session.step === 'lang'; + const [updated] = await db + .update(intakeSessions) + .set({ lang: picked, ...(first ? { step: 'deal' } : {}) }) + .where(eq(intakeSessions.id, session.id)) + .returning(); + const lt = intakeTexts(picked); + if (first) { + const [row] = await db + .select({ agent: users.name, company: companies.name }) + .from(intakeLinks) + .innerJoin(users, eq(users.id, intakeLinks.userId)) + .innerJoin(companies, eq(companies.id, intakeLinks.companyId)) + .where(eq(intakeLinks.token, session.linkToken)); + await tg.sendMessage(chatId, esc(lt.hello(row?.company ?? '', row?.agent ?? ''))); + return ask(updated!, 'deal'); + } + await tg.sendMessage(chatId, esc(lt.langSet)); + return updated!.step === 'done' ? undefined : ask(updated!, updated!.step as Step); + } + if (session.step === 'lang') { + if (update.callback_query) await tg.answerCallback(update.callback_query.id); + return void (await askLanguage()); + } + const st = intakeTexts(session.lang); + + // После анкеты: фото добавляются к карточке клиента. + if (session.step === 'done') { + if (update.callback_query) return tg.answerCallback(update.callback_query.id); + const photo = update.message?.photo?.at(-1)?.file_id; + const reply = update.message?.contact?.phone_number ?? text; + if (!photo && reply && session.clientId) { + return void (await tg.sendMessage(chatId, (await saveReply(db, session.clientId, reply)) ? st.replySaved : st.after)); + } + if (!photo || !session.clientId) return void (await tg.sendMessage(chatId, st.after)); + const [client] = await db.select().from(clients).where(eq(clients.id, session.clientId)); + if (!client) return void (await tg.sendMessage(chatId, st.after)); + const [{ n } = { n: 0 }] = await db.select({ n: sql`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, client.id)); + if (n >= 10) return void (await tg.sendMessage(chatId, st.photoLimit)); + const file = await download?.(photo).catch(() => null); + if (!file || file.data.length > 5 * 1024 * 1024) return void (await tg.sendMessage(chatId, st.photoFail)); + await db.insert(clientPhotos).values({ + clientId: client.id, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'), + }); + return void (await tg.sendMessage(chatId, st.photoSaved)); + } + + const step = session.step as Step; + let value = text; + let viaButton = false; + if (update.callback_query) { + const m = /^q:([a-zA-Z]+):(.+)$/.exec(update.callback_query.data ?? ''); + await tg.answerCallback(update.callback_query.id); + // Нажата кнопка старого вопроса — молча игнорируем. + if (!m || m[1] !== step) return; + value = m[2]!; + viaButton = true; + } else if (!value) { + return ask(session, step); + } + + const result = accept(step, session, value, viaButton); + if ('error' in result) return void (await tg.sendMessage(chatId, esc(result.error))); + const answers = { ...(session.answers as Answers), ...result.patch }; + const next = nextStep(step, answers); + if (next !== 'done') { + const [updated] = await db.update(intakeSessions).set({ step: next, answers: answers as Record }).where(eq(intakeSessions.id, session.id)).returning(); + return ask(updated!, next); + } + const { owner } = await complete(db, session, answers); + await tg.sendMessage(chatId, esc(st.done(owner.name))); +} + +/** Чат соискателя в клиентском боте — чтобы отправить ему ссылку на подборку. */ +export async function seekerChat(db: Db, clientId: number): Promise<{ chatId: number; lang: string } | null> { + const [s] = await db + .select({ chatId: intakeSessions.chatId, lang: intakeSessions.lang }) + .from(intakeSessions) + .where(eq(intakeSessions.clientId, clientId)) + .orderBy(desc(intakeSessions.id)) + .limit(1); + return s ?? null; +} diff --git a/apps/api/src/intake/texts.ts b/apps/api/src/intake/texts.ts new file mode 100644 index 0000000..03b29a8 --- /dev/null +++ b/apps/api/src/intake/texts.ts @@ -0,0 +1,97 @@ +import type { Lang } from '@lr/shared'; + +// Тексты клиентского бота: анкета соискателя жилья. + +const ru = { + hello: (company: string, agent: string) => `Здравствуйте! Это анкета агентства «${company}». Ответьте на несколько вопросов — и агент ${agent} подберёт для вас варианты.`, + noLink: 'Этот бот работает по ссылке от вашего агента. Попросите агента прислать ссылку на анкету.', + badLink: 'Ссылка на анкету устарела. Попросите агента прислать новую.', + deal: 'Что вы ищете?', rent: 'Снять', sale: 'Купить', + type: 'Какой объект нужен?', apartment: 'Квартира', house: 'Дом', commercial: 'Коммерческая', any: 'Не важно', + place: 'Где ищете?', yerevan: 'Весь Ереван', anywhere: 'Вся Армения', + budget: 'Какой максимальный бюджет? Напишите сумму и валюту, например: 600000 AMD или 1500 USD.', badBudget: 'Не понял сумму. Напишите число и валюту, например: 1500 USD.', + rooms: 'Сколько комнат?', rooms4: '4 и больше', + term: 'На какой срок нужна аренда?', termShort: 'До 6 месяцев', termMid: '6–12 месяцев', termLong: 'От года', + who: 'Кто будет жить?', alone: 'Один', friend: 'С другом', couple: 'С партнёром (пара)', + children: 'Будут ли жить дети?', none: 'Нет', more3: '3 и больше', + childrenAge: 'Сколько лет детям? Например: 3 и 7 лет.', + pets: 'Есть ли домашние животные?', more2: '2 и больше', + petsAge: 'Кто это и сколько лет? Например: кот, 2 года.', + name: 'Как вас зовут?', keepName: (n: string) => `Оставить «${n}»`, + phone: 'Оставьте номер телефона, чтобы агент мог с вами связаться.', badPhone: 'Похоже, это не номер телефона. Напишите его цифрами, например: 091 123 456.', + skip: 'Пропустить', + done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`, + photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.', + replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.', + after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.', + useButtons: 'Выберите вариант кнопкой под сообщением.', + langSet: 'Язык: русский. Сменить язык — команда /lang.', + selection: (n: number, agent: string, url: string) => `Агент ${agent} подобрал для вас варианты: ${n}. Посмотрите и запишитесь на показ:\n${url}`, +}; + +type Texts = typeof ru; + +const hy: Texts = { + hello: (company, agent) => `Բարև Ձեզ։ Սա «${company}» գործակալության հարցաթերթիկն է։ Պատասխանեք մի քանի հարցի, և գործակալ ${agent}-ը կընտրի Ձեզ համար տարբերակներ։`, + noLink: 'Այս բոտն աշխատում է Ձեր գործակալի հղումով։ Խնդրեք գործակալին ուղարկել հարցաթերթիկի հղումը։', + badLink: 'Հարցաթերթիկի հղումը հնացել է։ Խնդրեք գործակալին ուղարկել նորը։', + deal: 'Ի՞նչ եք փնտրում', rent: 'Վարձել', sale: 'Գնել', + type: 'Ի՞նչ օբյեկտ է պետք', apartment: 'Բնակարան', house: 'Տուն', commercial: 'Կոմերցիոն', any: 'Կարևոր չէ', + place: 'Որտե՞ղ եք փնտրում', yerevan: 'Ամբողջ Երևանը', anywhere: 'Ամբողջ Հայաստանը', + budget: 'Ո՞րն է առավելագույն բյուջեն։ Գրեք գումարը և արժույթը, օրինակ՝ 600000 AMD կամ 1500 USD։', badBudget: 'Գումարը չհասկացա։ Գրեք թիվը և արժույթը, օրինակ՝ 1500 USD։', + rooms: 'Քանի՞ սենյակ', rooms4: '4 և ավելի', + term: 'Ի՞նչ ժամկետով է պետք վարձակալությունը', termShort: 'Մինչև 6 ամիս', termMid: '6–12 ամիս', termLong: 'Մեկ տարուց ավելի', + who: 'Ո՞վ է ապրելու', alone: 'Մենակ', friend: 'Ընկերոջ հետ', couple: 'Զուգընկերոջ հետ (զույգ)', + children: 'Երեխաներ ապրելո՞ւ են', none: 'Ոչ', more3: '3 և ավելի', + childrenAge: 'Քանի՞ տարեկան են երեխաները։ Օրինակ՝ 3 և 7 տարեկան։', + pets: 'Ընտանի կենդանիներ ունե՞ք', more2: '2 և ավելի', + petsAge: 'Ո՞վ է և քանի՞ տարեկան։ Օրինակ՝ կատու, 2 տարեկան։', + name: 'Ինչպե՞ս է Ձեր անունը', keepName: (n) => `Թողնել «${n}»`, + phone: 'Թողեք հեռախոսահամարը, որպեսզի գործակալը կապվի Ձեզ հետ։', badPhone: 'Կարծես սա հեռախոսահամար չէ։ Գրեք թվերով, օրինակ՝ 091 123 456։', + skip: 'Բաց թողնել', + done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`, + photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։', + replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։', + after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։', + useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։', + langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։', + selection: (n, agent, url) => `Գործակալ ${agent}-ը Ձեզ համար ընտրել է տարբերակներ՝ ${n}։ Դիտեք և գրանցվեք ցուցադրության.\n${url}`, +}; + +const en: Texts = { + hello: (company, agent) => `Hello! This is the questionnaire of “${company}”. Answer a few questions and agent ${agent} will pick options for you.`, + noLink: 'This bot works by a link from your agent. Ask the agent to send you the questionnaire link.', + badLink: 'The questionnaire link has expired. Ask the agent for a new one.', + deal: 'What are you looking for?', rent: 'Rent', sale: 'Buy', + type: 'What kind of property?', apartment: 'Apartment', house: 'House', commercial: 'Commercial', any: 'Any', + place: 'Where are you looking?', yerevan: 'All of Yerevan', anywhere: 'All of Armenia', + budget: 'What is your maximum budget? Write the amount and currency, e.g. 600000 AMD or 1500 USD.', badBudget: 'I did not get the amount. Write a number and currency, e.g. 1500 USD.', + rooms: 'How many rooms?', rooms4: '4 or more', + term: 'How long do you need the rental for?', termShort: 'Up to 6 months', termMid: '6–12 months', termLong: 'A year or more', + who: 'Who will live there?', alone: 'Alone', friend: 'With a friend', couple: 'With a partner (couple)', + children: 'Will children live there?', none: 'No', more3: '3 or more', + childrenAge: 'How old are the children? E.g. 3 and 7 years.', + pets: 'Do you have pets?', more2: '2 or more', + petsAge: 'Who is it and how old? E.g. cat, 2 years.', + name: 'What is your name?', keepName: (n) => `Keep “${n}”`, + phone: 'Leave a phone number so the agent can contact you.', badPhone: 'That does not look like a phone number. Write it in digits, e.g. 091 123 456.', + skip: 'Skip', + done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`, + photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.', + replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.', + after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.', + useButtons: 'Choose an option with a button under the message.', + langSet: 'Language: English. To change it, use /lang.', + selection: (n, agent, url) => `Agent ${agent} has picked options for you: ${n}. Have a look and book a viewing:\n${url}`, +}; + +/** Вопрос о языке задаётся до того, как язык известен, — поэтому сразу на трёх. */ +export const LANGUAGE_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose your language'; +export const LANGUAGE_BUTTONS: { lang: Lang; text: string }[] = [ + { lang: 'ru', text: 'Русский' }, + { lang: 'hy', text: 'Հայերեն' }, + { lang: 'en', text: 'English' }, +]; + +const all: Record = { ru, hy, en }; +export const intakeTexts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru; diff --git a/apps/api/src/leads.int.test.ts b/apps/api/src/leads.int.test.ts new file mode 100644 index 0000000..0fcf5be --- /dev/null +++ b/apps/api/src/leads.int.test.ts @@ -0,0 +1,715 @@ +import { eq, gt, sql } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { blacklist, clients, companies, deals, events, leads, listings, outbox, parserRuns, seenListings, sessions, users } from './db/schema.ts'; +import { bumpedIds, isFresh, runIngest, type PagedSource } from './ingest/service.ts'; +import { runLeadJobs } from './leads/service.ts'; +import { runGroupPosts, type GroupSender } from './group/service.ts'; +import { testImage } from './testing.ts'; +import { LISTAM_CATEGORIES } from './parser/listam-source.ts'; +import type { SourcedListing } from './parser/source.ts'; +import { handleUpdate } from './telegram/bot.ts'; +import { TelegramError } from './telegram/client.ts'; +import { inQuietHours, processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// Сценарии второго этапа: парсер → фильтры → лиды, уведомления в Telegram, «Беру», пул. + +let t: Awaited>; +let tgId = 1000; +let extId = 1_000_000; + +/** Управляемый источник: что лежит в `pages`, то и «опубликовано» на list.am. */ +const pages = new Map(); +let sourceDown = false; +let detailsDown = false; +/** Объявления, которые «есть на list.am», но ещё не попали в общую базу: их агент добавляет ссылкой. */ +const items = new Map(); +const source: PagedSource = { + name: 'fake', + item: async (externalId) => items.get(externalId) ?? null, + listPage: async (category, page = 1) => { + if (sourceDown) throw new Error('Ридер ответил 503'); + return page === 1 ? (pages.get(category.id) ?? []) : []; + }, + inspect: async (card) => { + if (detailsDown) throw new Error('Ридер ответил 429'); + return { ...card, description: 'Светлая квартира', publishedAt: card.publishedAt ?? new Date(Date.now() - 40_000) }; + }, +}; +const SALE = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + +const listing = (over: Partial = {}): SourcedListing => { + const id = String(++extId); + return { + externalId: id, sourceUrl: `https://www.list.am/item/${id}`, title: '3-комн. квартира', dealType: 'sale', + propertyType: 'apartment', region: 'Ереван', district: 'Кентрон', price: 150_000, currency: 'USD', rooms: 3, + area: 80, floor: 4, floorsTotal: 9, isAgency: false, photoUrls: ['https://img.list.am/f/1.webp'], ...over, + }; +}; + +beforeAll(async () => { t = await testApp('leads', source); }); +afterAll(() => t.close()); + +const tg = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); }; +const publish = async (...items: SourcedListing[]) => { + pages.set(SALE.id, [...items, ...(pages.get(SALE.id) ?? [])]); + const report = await runIngest({ db: t.db, source }); + await flush(); + return report!; +}; + +interface Member { api: ReturnType; id: number; chat: number; name: string } + +const superadmin = async () => { + const [u] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x'), telegramId: ++tgId }) + .returning(); + return { api: t.client(await createSession(t.db, u!.id)), chat: u!.telegramId! }; +}; + +/** Компания с руководителем и агентами; у всех привязан Telegram. */ +const company = async (agents = 2, plan: 'solo' | 'team' = 'team', keepBase = false) => { + // Объявления из прошлых сценариев «состариваем»: иначе новый фильтр получил бы их из базы. + if (!keepBase) await t.db.update(listings).set({ publishedAt: new Date(Date.now() - 30 * 86_400_000), bumpedAt: null }); + const sa = await superadmin(); + const created = await sa.api.post('/api/admin/companies', { + name: 'Агентство', plan, billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const join = async (name: string, token: string): Promise => { + const api = await t.register(name); + await api.post(`/api/invites/${token}/accept`); + const id = (await api.get('/api/me')).body.user.id as number; + const chat = ++tgId; + await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, id)); + return { api, id, chat, name }; + }; + const boss = await join('Руководитель', (created.body.managerUrl as string).split('/invite/')[1]!); + const invite = agents ? ((await boss.api.post('/api/invites')).body.token as string) : ''; + const team: Member[] = []; + for (let i = 0; i < agents; i++) team.push(await join(`Агент ${i + 1}`, invite)); + await flush(); + return { sa, boss, agents: team, id: created.body.id as number }; +}; + +const commonFilter = (m: Member, params: object = {}) => + m.api.post('/api/filters', { name: 'Кентрон', scope: 'company', params: { districts: ['Кентрон'], ...params } }); + +/** Первый проход по категории только запоминает объявления — делаем его заранее. */ +const baseline = () => runIngest({ db: t.db, source }); + +describe('парсер → фильтры → лиды', () => { + it('первый проход наполняет базу без лидов; новое объявление становится лидом в пуле', async () => { + const c = await company(2); + await commonFilter(c.boss, { dealType: 'sale', propertyTypes: ['apartment'] }); + const old = listing({ publishedAt: new Date(Date.now() - 10 * 86_400_000) }); + pages.set(SALE.id, [listing(), listing(), old]); + const first = await runIngest({ db: t.db, source }); + await flush(); + // Читаются все 10 категорий; в базу попали два свежих объявления, старое — нет. + expect(first).toMatchObject({ status: 'ok', categories: 10, foundNew: 2, leads: 0 }); + expect(await t.db.select().from(listings).where(gt(listings.publishedAt, new Date(Date.now() - 86_400_000)))).toHaveLength(2); + expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toEqual([old.externalId]); + expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0); + + const before = tg.messages.length; + const report = await publish(listing({ title: 'Новая квартира на Абовяна', address: 'ул. Абовяна 10' })); + expect(report).toMatchObject({ foundNew: 1, leads: 1 }); + const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body; + expect(pool).toHaveLength(1); + expect(pool[0]).toMatchObject({ title: 'Новая квартира на Абовяна', assigneeId: null, source: 'listam', description: 'Светлая квартира' }); + + // Общий фильтр: уведомление всем участникам, с фото, ценой, районом и кнопкой «Беру». + const sent = tg.messages.slice(before); + expect(sent.map((m) => m.chatId).sort()).toEqual([c.boss.chat, ...c.agents.map((a) => a.chat)].sort()); + const m = sent[0]!; + expect(m.photo).toBe('https://img.list.am/f/1.webp'); + expect(m.text).toContain('$150 000'); + expect(m.text).toContain('Кентрон, ул. Абовяна 10'); + expect(m.text).toContain('3 комн. · 80 м² · этаж 4/9'); + expect(m.text).toContain('https://www.list.am/item/'); + expect(tg.hasButton(m, 'take:')).toBe(true); + + // Повторный проход того же объявления лид не дублирует. + expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, leads: 0 }); + }); + + it('объявление попадает в общую базу, даже если под него нет ни одного фильтра', async () => { + await baseline(); + const before = (await t.db.select().from(listings)).length; + const report = await publish(listing({ district: 'Нубарашен', price: 9_999_999 })); + expect(report.foundNew).toBe(1); + expect((await t.db.select().from(listings)).length).toBe(before + 1); + }); + + it('новый фильтр сразу получает подходящие объявления из базы — в пул, без уведомлений', async () => { + await baseline(); + await publish(listing({ district: 'Шенгавит', price: 70_000 }), listing({ district: 'Шенгавит', price: 900_000 })); + const c = await company(1, 'team', true); + const before = tg.messages.length; + const made = await c.boss.api.post('/api/filters', { name: 'Шенгавит', scope: 'company', params: { districts: ['Шенгавит'], priceTo: 100_000 } }); + expect(made.body.fromBase).toBe(1); + await flush(); + expect(tg.messages.length).toBe(before); + const pool = (await c.agents[0]!.api.get('/api/leads?tab=pool')).body; + expect(pool).toMatchObject([{ district: 'Шенгавит', price: 70_000, assigneeId: null }]); + + // Фильтр расширили — догружается остальное; уже созданный лид не дублируется. + const wider = await c.boss.api.patch(`/api/filters/${made.body.id}`, { params: { districts: ['Шенгавит'] } }); + expect(wider.body.fromBase).toBe(1); + expect((await c.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(2); + // Выключенный фильтр ничего не догружает — и правка названия его не включает обратно. + expect((await c.boss.api.patch(`/api/filters/${made.body.id}`, { enabled: false })).body.fromBase).toBe(0); + await c.boss.api.patch(`/api/filters/${made.body.id}`, { name: 'Шенгавит, все' }); + expect((await c.boss.api.get('/api/filters')).body).toMatchObject([{ name: 'Шенгавит, все', enabled: false }]); + }); + + it('свежесть: в базу идут объявления, размещённые не раньше чем 3 дня назад', () => { + const now = Date.UTC(2026, 9, 8, 12); // 8 октября, 16:00 по Еревану + const day = (d: number) => new Date(Date.UTC(2026, 9, d) - 4 * 3_600_000); // «Размещено d.10.2026» + expect(isFresh(day(8), now)).toBe(true); + expect(isFresh(day(5), now)).toBe(true); + expect(isFresh(day(4), now)).toBe(false); + }); + + it('порядок списка: поднятое объявление встаёт наверх, по нему нет лишних лидов', () => { + expect(bumpedIds(['5', '1', '2', '3', '4'], ['1', '2', '3', '4', '5'])).toEqual(new Set(['5'])); + // Сдвиг вниз — не поднятие. Незнакомое сверху тоже помечается: новое оно или поднятое, решает база. + expect(bumpedIds(['9', '1', '2', '3'], ['1', '2', '3', '4'])).toEqual(new Set(['9'])); + expect(bumpedIds(['1', '2', '3'], ['0', '1', '2', '3'])).toEqual(new Set()); + // Объявление, которого на прошлом проходе в начале списка не было, оказалось выше прежних. + expect(bumpedIds(['7', '1', '2'], ['1', '2'])).toEqual(new Set(['7'])); + // Без прошлого порядка поднятие не определить. + expect(bumpedIds(['2', '1'], [])).toEqual(new Set()); + }); + + it('поднятое объявление: старое идёт в базу лидом, знакомое встаёт наверх; «Топ» — по дате', async () => { + const c = await company(0); + await commonFilter(c.boss); + await baseline(); + const list = async () => (await c.boss.api.get('/api/leads?tab=all')).body as { id: number; url: string; bumpedAt: string | null }[]; + + // Объявление недельной давности, которое автор поднял: в базе его не было — теперь это лид. + const old = listing({ publishedAt: new Date(Date.now() - 7 * 86_400_000) }); + // Лиды получают и компании из других сценариев, поэтому смотрим свой список. + expect(await publish(old)).toMatchObject({ foundNew: 1 }); + expect((await list())[0]).toMatchObject({ url: old.sourceUrl, bumpedAt: expect.any(String) }); + // Даже с ID далеко ниже последних. + expect(await publish(listing({ externalId: '10', sourceUrl: 'https://www.list.am/item/10' }))).toMatchObject({ foundNew: 1 }); + + // Объявление из базы ушло вниз, затем его подняли: лид встаёт наверх, второго лида нет. + const known = listing(); + await publish(known); + await publish(listing()); + expect((await list())[0]!.url).not.toBe(known.sourceUrl); + pages.set(SALE.id, [known, ...pages.get(SALE.id)!.filter((x) => x.externalId !== known.externalId)]); + expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0, bumped: 1, leads: 0 }); + expect((await list())[0]).toMatchObject({ url: known.sourceUrl, bumpedAt: expect.any(String) }); + // Следующий проход то же поднятие повторно не считает. + expect(await runIngest({ db: t.db, source })).toMatchObject({ bumped: 0 }); + + // Блок «Топ» ротируется: старое объявление оттуда в базу не идёт. + const top = listing({ promoted: true, publishedAt: new Date(Date.now() - 7 * 86_400_000) }); + pages.set(SALE.id, [...pages.get(SALE.id)!, top]); + expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 0 }); + expect((await t.db.select().from(seenListings)).map((r) => r.externalId)).toContain(top.externalId); + // А поднятое в обычном списке ранее пропущенное объявление — идёт. + pages.set(SALE.id, [{ ...top, promoted: false }, ...pages.get(SALE.id)!.filter((x) => x.externalId !== top.externalId)]); + expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 }); + }); + + it('страницу объявления не удалось прочитать — оно повторяется на следующем проходе', async () => { + const c = await company(0); + await commonFilter(c.boss); + await baseline(); + const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number; + detailsDown = true; + const failed = await publish(listing()); + expect(failed).toMatchObject({ foundNew: 0 }); + expect(failed.errors[0]).toContain('429'); + detailsDown = false; + // Лиды получают и компании из других сценариев, поэтому считаем только свою. + expect(await runIngest({ db: t.db, source })).toMatchObject({ foundNew: 1 }); + expect(await count()).toBe(1); + }); + + it('личный фильтр: уведомление получает только его владелец; пауза и тихие часы', async () => { + const c = await company(3); + const [a1, a2, a3] = c.agents as [Member, Member, Member]; + await baseline(); + await a1.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } }); + await a2.api.post('/api/filters', { name: 'Мой', scope: 'personal', params: { districts: ['Арабкир'] } }); + await a2.api.patch('/api/me', { notificationsPaused: true }); + expect((await a3.api.post('/api/filters', { name: 'Общий', scope: 'company', params: {} })).status).toBe(403); + + const before = tg.messages.length; + await publish(listing({ district: 'Арабкир' })); + expect(tg.messages.slice(before).map((m) => m.chatId)).toEqual([a1.chat]); + // Лид в пуле виден всем, даже тем, кто не получил уведомление. + expect((await a3.api.get('/api/leads?tab=pool')).body).toHaveLength(1); + + const at = (h: number) => new Date(Date.UTC(2026, 9, 8, h - 4, 30)); // h — час по Еревану + expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(23))).toBe(true); + expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(7))).toBe(true); + expect(inQuietHours({ quietFrom: '22:00', quietTo: '08:00' }, at(12))).toBe(false); + expect(inQuietHours({ quietFrom: '13:00', quietTo: '14:00' }, at(13))).toBe(true); + expect(inQuietHours({ quietFrom: null, quietTo: null }, at(13))).toBe(false); + }); + + it('«только собственники»: метка агентства, порог N и чёрный список не создают лид', async () => { + const c = await company(0); + await commonFilter(c.boss); + await baseline(); + const count = async () => (await c.boss.api.get('/api/leads?tab=all')).body.length as number; + + await publish(listing({ isAgency: true })); + expect(await count()).toBe(0); + + // Порог N = 3: четвёртое активное объявление с одного номера — уже агент. + const phone = '+37491000111'; + await publish(listing({ phone }), listing({ phone }), listing({ phone })); + expect(await count()).toBe(3); + await publish(listing({ phone })); + expect(await count()).toBe(3); + + await t.db.insert(blacklist).values({ companyId: c.id, phone: '+37477000222' }); + await publish(listing({ phone: '077 000 222' })); + expect(await count()).toBe(3); + + // «Все объявления» — агентские тоже приходят. + // Такой фильтр сразу забирает из базы три отсеянных выше объявления. + const all = await c.boss.api.post('/api/filters', { name: 'Все', scope: 'company', params: { districts: ['Кентрон'], ownersOnly: false } }); + expect(all.body.fromBase).toBe(3); + await publish(listing({ isAgency: true })); + expect(await count()).toBe(7); + }); + + it('одно объявление порождает лиды в разных компаниях; компания без доступа лидов не получает', async () => { + const a = await company(0); + const b = await company(0); + const off = await company(0); + for (const c of [a, b, off]) await commonFilter(c.boss); + await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, off.id)); + await baseline(); + await publish(listing()); + expect((await a.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1); + expect((await b.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(1); + expect((await off.boss.api.get('/api/leads?tab=pool')).body).toHaveLength(0); + // Чужой лид недоступен по ID. + const idA = (await a.boss.api.get('/api/leads?tab=pool')).body[0].id; + expect((await b.boss.api.get(`/api/leads/${idA}`)).status).toBe(404); + expect((await b.boss.api.post(`/api/leads/${idA}/take`)).status).toBe(404); + }); + + it('сбой источника: запуск помечен ошибкой, суперадмин получает алерт в Telegram', async () => { + const c = await company(0); + await commonFilter(c.boss); + sourceDown = true; + const report = await runIngest({ db: t.db, source }); + sourceDown = false; + await flush(); + expect(report).toMatchObject({ status: 'failed' }); + expect(tg.inbox(c.sa.chat).at(-1)!.text).toContain('Ридер ответил 503'); + const [run] = await t.db.select().from(parserRuns).where(eq(parserRuns.status, 'failed')); + expect(run!.errors).toContain('503'); + expect((await c.sa.api.get('/api/admin/parser')).body).toMatchObject({ enabled: true, source: 'fake' }); + }); +}); + +/** У каждого лида свой номер: иначе сработал бы порог «больше 3 объявлений — агент». */ +let phoneSeq = 100000; +let lastPhone = ''; +const OWNER_PHONE = () => (lastPhone = `+37493${++phoneSeq}`); + +describe('«Беру» и пул', () => { + const withLead = async (agents = 2) => { + const c = await company(agents); + await commonFilter(c.boss); + await baseline(); + await publish(listing({ phone: OWNER_PHONE() })); + const leadId = (await c.boss.api.get('/api/leads?tab=pool')).body[0].id as number; + return { ...c, leadId }; + }; + + it('при одновременном нажатии лид получает один агент, второй видит «Уже взят»', async () => { + const c = await withLead(2); + const [a1, a2] = c.agents as [Member, Member]; + const results = await Promise.all([a1.api.post(`/api/leads/${c.leadId}/take`), a2.api.post(`/api/leads/${c.leadId}/take`)]); + expect(results.map((r) => r.status).sort()).toEqual([200, 409]); + expect(results.find((r) => r.status === 409)!.body.code).toBe('ALREADY_TAKEN'); + const winner = results[0]!.status === 200 ? a1 : a2; + const loser = winner === a1 ? a2 : a1; + await flush(); + + // У победителя — «Вы взяли», у остальных — «Взял(а) Имя», кнопка «Беру» исчезла у всех. + const msg = (m: Member) => tg.inbox(m.chat).at(-1)!; + expect(msg(winner).text).toContain('Вы взяли'); + expect(msg(loser).text).toContain(`Взял(а) ${winner.name}`); + expect(msg(c.boss).text).toContain(`Взял(а) ${winner.name}`); + for (const m of [winner, loser, c.boss]) expect(tg.hasButton(msg(m), 'take:')).toBe(false); + + const card = (await winner.api.get(`/api/leads/${c.leadId}`)).body; + expect(card).toMatchObject({ assigneeId: winner.id, assigneeName: winner.name, phone: lastPhone }); + expect(card.events.map((e: { type: string }) => e.type)).toEqual(['taken', 'created']); + expect((await winner.api.get('/api/leads?tab=mine')).body).toHaveLength(1); + expect((await loser.api.get('/api/leads?tab=pool')).body).toHaveLength(0); + }); + + it('кнопка «Беру» в боте; чужой Telegram и взятый лид получают отказ', async () => { + const c = await withLead(2); + const [a1, a2] = c.agents as [Member, Member]; + const press = (chat: number, id: string) => + handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, callback_query: { id, from: { id: chat }, data: `take:${c.leadId}` } }); + await press(999_999, 'cb-stranger'); + await press(a1.chat, 'cb-1'); + await press(a2.chat, 'cb-2'); + const answer = (id: string) => tg.answers.find((a) => a.id === id)!.text; + expect(answer('cb-stranger')).toContain('привяжите Telegram'); + expect(answer('cb-1')).toBe('Объект ваш'); + expect(answer('cb-2')).toBe('Уже взят'); + expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id); + }); + + it('видимость: агент не видит чужие лиды, пока руководитель не включит «видят все»', async () => { + const c = await withLead(2); + const [a1, a2] = c.agents as [Member, Member]; + await a1.api.post(`/api/leads/${c.leadId}/take`); + expect((await a2.api.get(`/api/leads/${c.leadId}`)).status).toBe(404); + expect((await a2.api.get('/api/leads?tab=all')).status).toBe(403); + expect((await c.boss.api.get('/api/leads?tab=all')).body).toHaveLength(1); + + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + const seen = (await a2.api.get(`/api/leads/${c.leadId}`)).body; + expect(seen).toMatchObject({ assigneeId: a1.id, phone: null, phoneHidden: true }); + expect((await a2.api.get('/api/leads?tab=all')).body).toHaveLength(1); + // Видеть — не значит вести: статус чужого лида агент не меняет. + expect((await a2.api.post(`/api/leads/${c.leadId}/comments`, { text: 'мой' })).body.code).toBe('NOT_YOUR_LEAD'); + }); + + it('через 24 часа без смены статуса лид возвращается в пул: предупреждение, потеря, повторное «Беру»', async () => { + const c = await withLead(2); + const [a1, a2] = c.agents as [Member, Member]; + await a1.api.post(`/api/leads/${c.leadId}/take`); + await flush(); + const hoursAgo = (h: number) => t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - h * 3_600_000) }).where(eq(leads.id, c.leadId)); + + await hoursAgo(21); + expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 }); + + await hoursAgo(22.01); + expect(await runLeadJobs(t.db)).toMatchObject({ warned: 1, returned: 0 }); + expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0 }); // предупреждение одно + await flush(); + expect(tg.inbox(a1.chat).at(-1)!.text).toContain('вернётся в пул через 2 ч'); + + await hoursAgo(24.1); + const before = tg.messages.length; + expect(await runLeadJobs(t.db)).toMatchObject({ returned: 1 }); + await flush(); + const after = tg.messages.slice(before); + expect(after.find((m) => m.chatId === a1.chat)!.text).toContain('вернулся в пул'); + const again = after.filter((m) => tg.hasButton(m, 'take:')); + expect(again.map((m) => m.chatId).sort()).toEqual([c.boss.chat, a2.chat].sort()); + expect(again[0]!.text).toContain('снова в пуле'); + + const card = (await a2.api.get(`/api/leads/${c.leadId}`)).body; + expect(card.assigneeId).toBeNull(); + expect(card.events.map((e: { type: string }) => e.type)).toEqual(['pool_return', 'taken', 'created']); + expect(card.events[0]).toMatchObject({ userName: null, oldValue: a1.name }); + expect((await a2.api.post(`/api/leads/${c.leadId}/take`)).status).toBe(200); + }); + + it('смена статуса сбрасывает таймер; после «Договор» лид в пул не возвращается', async () => { + const c = await withLead(1); + const a1 = c.agents[0]!; + await a1.api.post(`/api/leads/${c.leadId}/take`); + const statuses = (await a1.api.get('/api/pipelines/object')).body.statuses as { id: number; name: string; code: string | null; poolReturn: boolean }[]; + expect(statuses.filter((s) => s.poolReturn).map((s) => s.name)).toEqual(['Взят в работу', 'Дозвонился', 'Встреча с собственником']); + const contract = statuses.find((s) => s.name.startsWith('Договор'))!; + await a1.api.post(`/api/leads/${c.leadId}/status`, { statusId: contract.id }); + await t.db.update(leads).set({ statusChangedAt: new Date(Date.now() - 100 * 3_600_000) }).where(eq(leads.id, c.leadId)); + expect(await runLeadJobs(t.db)).toMatchObject({ warned: 0, returned: 0 }); + expect((await a1.api.get(`/api/leads/${c.leadId}`)).body.assigneeId).toBe(a1.id); + }); + + it('руководитель переназначает лид: оба агента получают уведомление', async () => { + const c = await withLead(2); + const [a1, a2] = c.agents as [Member, Member]; + expect((await a1.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id })).status).toBe(403); + await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a1.id }); + await flush(); + expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Вам назначен объект'); + expect(tg.inbox(a2.chat).at(-1)!.text).toContain(`Взял(а) ${a1.name}`); + + await c.boss.api.post(`/api/leads/${c.leadId}/assign`, { userId: a2.id }); + await flush(); + expect(tg.inbox(a2.chat).at(-1)!.text).toContain('Вам назначен объект'); + expect(tg.inbox(a1.chat).at(-1)!.text).toContain(`передан агенту ${a2.name}`); + const card = (await c.boss.api.get(`/api/leads/${c.leadId}`)).body; + expect(card.events[0]).toMatchObject({ type: 'assigned', oldValue: a1.name, newValue: a2.name }); + }); + + it('отказ требует причину, сделка — комиссию; «Это агент» заносит номер в чёрный список', async () => { + const c = await withLead(1); + const a1 = c.agents[0]!; + const { statuses, reasons } = (await a1.api.get('/api/pipelines/object')).body as { + statuses: { id: number; code: string | null }[]; reasons: { id: number; code: string | null }[]; + }; + const st = (code: string) => statuses.find((s) => s.code === code)!.id; + const move = (body: object) => a1.api.post(`/api/leads/${c.leadId}/status`, body); + + expect((await move({ statusId: st('taken') })).body.code).toBe('TAKE_FIRST'); + await a1.api.post(`/api/leads/${c.leadId}/take`); + expect((await move({ statusId: st('new') })).body.code).toBe('USE_RETURN_TO_POOL'); + expect((await move({ statusId: st('rejected') })).body.code).toBe('REJECT_REASON_REQUIRED'); + expect((await move({ statusId: st('deal') })).body.code).toBe('COMMISSION_REQUIRED'); + expect((await move({ statusId: st('deal'), deal: { commission: 2500, currency: 'USD', date: '2026-10-08' } })).status).toBe(200); + const card = (await a1.api.get(`/api/leads/${c.leadId}`)).body; + expect(card.events[0]).toMatchObject({ type: 'deal', newValue: 'Сделка', text: '2500 USD' }); + + // Отдельный лид: «Это агент». + await publish(listing({ phone: '+37455999888' })); + const second = (await a1.api.get('/api/leads?tab=pool')).body[0].id as number; + expect((await a1.api.post(`/api/leads/${second}/agent`)).status).toBe(200); + const rejected = (await a1.api.get(`/api/leads/${second}`)).body; + expect(rejected.statusId).toBe(st('rejected')); + expect(rejected.rejectReasonId).toBe(reasons.find((r) => r.code === 'agent')!.id); + expect(await t.db.select().from(blacklist).where(eq(blacklist.companyId, c.id))).toMatchObject([{ phone: '+37455999888' }]); + // Следующее объявление с этого номера лид уже не создаёт. + await publish(listing({ phone: '+37455999888' })); + expect((await a1.api.get('/api/leads?tab=pool')).body).toHaveLength(0); + }); + + it('лид вручную, комментарий, напоминание, возврат в пул; при ограниченном доступе — только чтение', async () => { + const c = await company(1); + const a1 = c.agents[0]!; + const made = await a1.api.post('/api/leads', { title: 'Дом в Абовяне', phone: '091 22 33 44', price: 90000, region: 'Котайк' }); + expect(made.status).toBe(200); + const id = made.body.id as number; + expect((await a1.api.get(`/api/leads/${id}`)).body).toMatchObject({ source: 'manual', assigneeId: a1.id, phone: '+37491223344' }); + + await a1.api.post(`/api/leads/${id}/comments`, { text: 'Перезвонить вечером' }); + await a1.api.post(`/api/leads/${id}/next-action`, { at: new Date(Date.now() - 1000).toISOString(), note: 'Звонок' }); + expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 1 }); + expect(await runLeadJobs(t.db)).toMatchObject({ reminded: 0 }); + await flush(); + expect(tg.inbox(a1.chat).at(-1)!.text).toContain('Напоминание по объекту «Дом в Абовяне»: Звонок'); + expect((await a1.api.get('/api/leads?tab=mine&q=223344')).body).toHaveLength(1); + expect((await a1.api.get('/api/leads?tab=mine&q=Абовян')).body).toHaveLength(1); + expect((await a1.api.get('/api/leads?tab=mine&q=Гюмри')).body).toHaveLength(0); + + await a1.api.post(`/api/leads/${id}/release`); + expect((await a1.api.get('/api/leads-summary')).body).toMatchObject({ pool: 1, mine: 0 }); + + await t.db.update(companies).set({ subscriptionEndsAt: new Date(Date.now() - 16 * 86_400_000) }).where(eq(companies.id, c.id)); + expect((await a1.api.post(`/api/leads/${id}/take`)).body.code).toBe('SUBSCRIPTION_REQUIRED'); + expect((await a1.api.get(`/api/leads/${id}`)).status).toBe(200); + }); +}); + +describe('очередь Telegram и бот', () => { + it('при ошибке отправки сообщение повторяется; заблокированный бот не повторяется', async () => { + const c = await company(0); + await flush(); + const send = async () => { + await t.db.insert(outbox).values({ kind: 'text', userId: c.boss.id, payload: { raw: 'Привет <всем>' } }); + const [job] = await t.db.select().from(outbox).where(eq(outbox.userId, c.boss.id)).orderBy(outbox.id).then((r) => r.slice(-1)); + return job!.id; + }; + const job = (id: number) => t.db.select().from(outbox).where(eq(outbox.id, id)).then((r) => r[0]!); + + const first = await send(); + tg.failOnce(new Error('network down')); + await flush(); + expect(await job(first)).toMatchObject({ doneAt: null, attempts: 1, error: 'network down' }); + await t.db.update(outbox).set({ runAt: sql`now() - interval '1 second'` }).where(eq(outbox.id, first)); + await flush(); + expect((await job(first)).doneAt).not.toBeNull(); + expect(tg.inbox(c.boss.chat).at(-1)!.text).toBe('Привет <всем>'); + + const second = await send(); + tg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user')); + await flush(); + expect((await job(second)).error).toContain('failed: Forbidden'); + expect((await job(second)).doneAt).not.toBeNull(); + }); + + it('привязка Telegram по ссылке из кабинета, /pause и /resume', async () => { + const api = await t.register('Нарек'); + const url = (await api.post('/api/me/telegram-link')).body.url as string; + expect(url).toMatch(/^https:\/\/t\.me\/test_bot\?start=/); + const code = url.split('start=')[1]!; + const chat = ++tgId; + const say = (text: string, from = chat) => + handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: 1, message: { chat: { id: from }, from: { id: from, username: 'narek' }, text } }); + + await say('/start wrong-code'); + expect(tg.inbox(chat).at(-1)!.text).toContain('устарела'); + await say(`/start ${code}`); + expect(tg.inbox(chat).at(-1)!.text).toContain('Готово, Нарек'); + expect((await api.get('/api/me')).body.user.telegramLinked).toBe(true); + await say(`/start ${code}`, ++tgId); // код одноразовый + expect(tg.inbox(tgId).at(-1)!.text).toContain('устарела'); + + await say('/pause'); + expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(true); + await say('/resume'); + expect((await api.get('/api/me')).body.user.notificationsPaused).toBe(false); + }); + + it('руководитель узнаёт в боте о новом агенте и о достижении лимита', async () => { + const c = await company(0, 'team'); + const invite = (await c.boss.api.post('/api/invites')).body.token as string; + for (let i = 0; i < 9; i++) await (await t.register(`Новичок ${i}`)).post(`/api/invites/${invite}/accept`); + await flush(); + const inbox = tg.inbox(c.boss.chat).map((m) => m.text); + expect(inbox.filter((x) => x.includes('присоединился к компании'))).toHaveLength(9); + expect(inbox.at(-1)).toContain('Достигнут лимит участников'); + }); +}); + +describe('объект по ссылке на list.am, районы, формулировки и удаление', () => { + it('свободный объект закрепляется за агентом, занятый — показывает чей, нового — скачивается', async () => { + const c = await company(2); + await commonFilter(c.boss, { dealType: 'sale' }); + await baseline(); + const [a1, a2] = c.agents as [Member, Member]; + const free = listing(); + await publish(free); + const url = `https://www.list.am/ru/item/${free.externalId}?w=1`; + const taken = await a1.api.post('/api/leads/by-url', { url }); + expect(taken.body).toMatchObject({ result: 'taken' }); + expect((await a1.api.get(`/api/leads/${taken.body.id}`)).body).toMatchObject({ assigneeId: a1.id }); + expect((await a1.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'mine', id: taken.body.id }); + // Чужой лид агенту без общей видимости не открыть — только имя. + expect((await a2.api.post('/api/leads/by-url', { url })).body).toEqual({ result: 'busy', id: null, assigneeName: 'Агент 1' }); + + // Объявления нет в CRM и в общей базе: скачивается с list.am и сразу закрепляется за агентом. + const fresh = listing({ title: '1-комн. квартира у метро', district: 'Арабкир' }); + items.set(fresh.externalId, fresh); + const created = await a2.api.post('/api/leads/by-url', { url: `list.am/item/${fresh.externalId}` }); + expect(created.body).toMatchObject({ result: 'created' }); + const card = (await a2.api.get(`/api/leads/${created.body.id}`)).body; + expect(card).toMatchObject({ title: '1-комн. квартира у метро', assigneeId: a2.id, district: 'Арабкир', source: 'listam' }); + // Тот же объект ссылкой у первого агента — занят вторым. + expect((await a1.api.post('/api/leads/by-url', { url: fresh.sourceUrl })).body).toMatchObject({ result: 'busy', assigneeName: 'Агент 2' }); + + expect((await a1.api.post('/api/leads/by-url', { url: 'https://www.list.am/item/99999999' })).body.code).toBe('LISTAM_NOT_FOUND'); + expect((await a1.api.post('/api/leads/by-url', { url: 'https://example.com/1' })).body.code).toBe('BAD_LISTAM_URL'); + + // Фильтр по нескольким районам. + const ids = async (districts: string) => ((await c.boss.api.get(`/api/leads?tab=all&districts=${encodeURIComponent(districts)}`)).body as { id: number }[]).map((l) => l.id); + expect(await ids('Кентрон,Арабкир')).toEqual(expect.arrayContaining([taken.body.id, created.body.id])); + expect(await ids('арабкир')).toEqual([created.body.id]); + expect(await ids('Шенгавит')).toEqual([]); + }); + + it('объявление посредника или собственника против агентов не приходит лидом', async () => { + const c = await company(1); + await commonFilter(c.boss, { dealType: 'sale', ownersOnly: true }); + await baseline(); + await publish(listing({ title: '2-комн. квартира, агентам не беспокоить' }), listing({ title: '2-комн. квартира' })); + const pool = (await c.boss.api.get('/api/leads?tab=pool')).body as { title: string }[]; + expect(pool.map((l) => l.title)).toEqual(['2-комн. квартира']); + }); + + it('карточки удаляет руководитель; карточку со сделкой — только суперадмин', async () => { + const c = await company(1); + const [agent] = c.agents as [Member]; + const id = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number; + await t.db.insert(events).values({ companyId: c.id, leadId: id, type: 'comment', text: 'x' }); + expect((await agent.api.del(`/api/leads/${id}`)).status).toBe(403); + expect((await c.boss.api.del(`/api/leads/${id}`)).status).toBe(200); + expect(await t.db.select().from(leads).where(eq(leads.id, id))).toHaveLength(0); + + const client = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant', telegram: 'https://t.me/gor_k', rentTerm: 'long' })).body.id as number; + expect((await agent.api.get(`/api/clients/${client}`)).body).toMatchObject({ telegram: '@gor_k', rentTerm: 'long' }); + const withDeal = (await agent.api.post('/api/leads', { title: 'Квартира' })).body.id as number; + await t.db.insert(deals).values({ + companyId: c.id, leadId: withDeal, clientId: client, agentId: agent.id, commission: 100, ownerCommission: 50, clientCommission: 50, + ownerAgentId: agent.id, clientAgentId: agent.id, currency: 'USD', dealDate: '2026-10-01', + }); + expect((await c.boss.api.del(`/api/leads/${withDeal}`)).body.code).toBe('HAS_DEAL'); + expect((await c.boss.api.del(`/api/clients/${client}`)).body.code).toBe('HAS_DEAL'); + + // Суперадмин в кабинете компании: объект удаляется, сделка остаётся за клиентом; потом и клиент. + const [sa] = await t.db.select().from(users).where(eq(users.role, 'superadmin')); + const token = await createSession(t.db, sa!.id); + await t.db.update(sessions).set({ actingCompanyId: c.id }); + const admin = t.client(token); + expect((await admin.del(`/api/leads/${withDeal}`)).status).toBe(200); + expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toMatchObject([{ leadId: null }]); + expect((await admin.del(`/api/clients/${client}`)).status).toBe(200); + expect(await t.db.select().from(clients).where(eq(clients.id, client))).toHaveLength(0); + expect(await t.db.select().from(deals).where(eq(deals.clientId, client))).toHaveLength(0); + }); +}); + +describe('группа компании в Telegram', () => { + it('объект в работе с комиссией уходит в группу; новая комиссия — снова; токен в кабинет не попадает', async () => { + const c = await company(1); + const [agent] = c.agents as [Member]; + const [co] = await t.db.select().from(companies).where(eq(companies.id, c.id)); + await t.db.update(companies).set({ settings: { ...co!.settings, groupBot: { token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 } } }).where(eq(companies.id, c.id)); + expect((await c.boss.api.get('/api/me')).body.company.settings.groupBot).toBeUndefined(); + // Сохранение настроек руководителем бот группы не стирает. + await c.boss.api.patch('/api/company', { agentsSeeAll: true }); + + const sent: { kind: string; chatId: number; token: string; threadId?: number; text?: string; photos?: number }[] = []; + const sender: GroupSender = { + album: async (bot, photos) => void sent.push({ kind: 'album', ...bot, photos: photos.length }), + text: async (bot, text) => void sent.push({ kind: 'text', ...bot, text }), + }; + const run = () => runGroupPosts({ db: t.db, config: t.config, sender, fetchImage: async () => testImage, pause: 0 }); + + const id = (await agent.api.post('/api/leads', { title: '2-комн. квартира', district: 'Кентрон', dealType: 'rent', price: 350000, currency: 'AMD' })).body.id as number; + // Без комиссии в карточке — не отправляется. + expect(await run()).toBe(0); + await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 50 } }); + expect(await run()).toBe(1); + // Описание и отдельным сообщением — кто дозвонился и комиссия. + expect(sent).toMatchObject([{ kind: 'text', token: 'GROUP:TOKEN', chatId: -100500, threadId: 7 }, { kind: 'text' }]); + expect(sent[0]!.text).toContain('2-комн. квартира'); + expect(sent[0]!.text).toContain('🏡 Кентрон'); + expect(sent[0]!.text).not.toContain('Дозвонился'); + expect(sent[1]!.text).toBe('👤 Дозвонился: Агент 1\n💰 Комиссия: 50 % (≈ 175 000 ֏)'); + expect(await run()).toBe(0); + + await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'fixed', value: 300, currency: 'USD' } }); + expect(await run()).toBe(1); + expect(sent.at(-2)!.text).toContain('Комиссия изменилась'); + expect(sent.at(-1)!.text).toContain('Комиссия: $300'); + + // Объект с фото из объявления: фото уходят альбомом перед текстом. + const photoLead = (await agent.api.post('/api/leads', { title: 'Дом' })).body.id as number; + await t.db.update(leads).set({ photos: ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/1/2.webp'] }).where(eq(leads.id, photoLead)); + await agent.api.post(`/api/leads/${photoLead}/commission`, { rule: { mode: 'percent', value: 100 } }); + await run(); + expect(sent.slice(-3).map((m) => m.kind)).toEqual(['album', 'text', 'text']); + expect(sent.at(-3)!.photos).toBe(2); + + // Кнопка «Отправить в Telegram»: сразу, комиссия — из настроек компании, если в карточке её нет. + const manual = (await agent.api.post('/api/leads', { title: 'Офис', dealType: 'sale', price: 100000, currency: 'USD' })).body.id as number; + t.groupSent.length = 0; + expect((await c.boss.api.get('/api/me')).body.company.groupBot).toBe(true); + expect((await agent.api.post(`/api/leads/${manual}/group`)).status).toBe(200); + expect(t.groupSent).toMatchObject([{ kind: 'text', chatId: -100500, threadId: 7 }, { kind: 'text' }]); + expect(t.groupSent[0]!.text).toContain('Офис'); + expect(t.groupSent[1]!.text).toContain('Дозвонился: Агент 1'); + // Без бота у компании — понятная ошибка. + const other = await company(0); + const foreign = (await other.boss.api.post('/api/leads', { title: 'Дом' })).body.id as number; + expect((await other.boss.api.get('/api/me')).body.company.groupBot).toBe(false); + expect((await other.boss.api.post(`/api/leads/${foreign}/group`)).body.code).toBe('GROUP_BOT_NOT_SET'); + + // Отказ: смена комиссии в группу уже не уходит. + const rejected = (await c.boss.api.get('/api/pipelines/object')).body; + const status = rejected.statuses.find((x: { code: string }) => x.code === 'rejected'); + await agent.api.post(`/api/leads/${id}/status`, { statusId: status.id, rejectReasonId: rejected.reasons[0].id }); + await agent.api.post(`/api/leads/${id}/commission`, { rule: { mode: 'percent', value: 30 } }); + expect(await run()).toBe(0); + }); +}); diff --git a/apps/api/src/leads/routes.ts b/apps/api/src/leads/routes.ts new file mode 100644 index 0000000..3ce480b --- /dev/null +++ b/apps/api/src/leads/routes.ts @@ -0,0 +1,611 @@ +import { agentPhrase, CommissionRule, CURRENCIES, DEAL_TYPES, FilterParams, PROPERTY_TYPES, type AccessState, type Role } from '@lr/shared'; +import { commissionDelta, commissionNote, commissionView, ruleCode } from '../lib/commission.ts'; +import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, or, sql, type SQL } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { DealBody, dealView } from '../deals/service.ts'; +import type { Db } from '../db/client.ts'; +import { clientLeads, clients, companies, deals, departments, events, filters, leadFavorites, leads, leadViews, listings, pipelineStatuses, users } from '../db/schema.ts'; +import { loadBillingSettings } from '../billing/service.ts'; +import { backfillFilter, importListing, type PagedSource } from '../ingest/service.ts'; +import { sendLeadToGroup, sendLeadToUser, type GroupSender } from '../group/service.ts'; +import { badRequest, forbidden, notFound } from '../lib/errors.ts'; +import { onlySent } from '../lib/patch.ts'; +import type { Config } from '../lib/config.ts'; +import { croppedPhoto, type ImageFetcher } from '../showcase/media.ts'; +import { renderShowcaseText } from '../showcase/text.ts'; +import { + addComment, + assignLead, + canSee, + changeStatus, + claimFoundLead, + createClaimedLead, + sameDepartment, + departmentScope, + inDepartment, + leadActual, + priceRange, + createManualLead, + deleteLead, + findListingLead, + editLead, + loadLead, + markAsAgent, + markListingRemoved, + releaseLead, + phoneVariants, + setLeadPhone, + setNextAction, + statusByCode, + takeLead, + type Actor, + type Lead, +} from './service.ts'; + +type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState }; + +export interface Guards { + member: (req: FastifyRequest) => MemberCtx; + manager: (req: FastifyRequest) => MemberCtx; + writable: (ctx: T) => T; +} + +const Id = z.object({ id: z.coerce.number().int().positive() }); +const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined); +const optionalNumber = z.coerce.number().nonnegative().optional(); + +const ManualLeadBody = z.object({ + title: z.string().trim().min(1).max(200), + description: optionalText(10_000), + dealType: z.enum(DEAL_TYPES).optional(), + propertyType: z.enum(PROPERTY_TYPES).optional(), + region: optionalText(60), + district: optionalText(60), + address: optionalText(300), + price: optionalNumber, + currency: z.enum(CURRENCIES).default('USD'), + rooms: z.coerce.number().int().min(0).max(50).optional(), + area: optionalNumber, + floor: z.coerce.number().int().min(-5).max(200).optional(), + floorsTotal: z.coerce.number().int().min(0).max(200).optional(), + authorName: optionalText(100), + phone: optionalText(40), + url: z.url().max(1000).optional().or(z.literal('').transform(() => undefined)), +}); + +/** Пустое поле в правке стирает значение. */ +const clearable = (schema: T) => z.preprocess((v) => (v === '' ? null : v), schema.nullable()); + +const LeadEditBody = z.object({ + title: z.string().trim().min(1).max(200), + description: clearable(z.string().trim().max(10_000)), + dealType: clearable(z.enum(DEAL_TYPES)), + propertyType: clearable(z.enum(PROPERTY_TYPES)), + region: clearable(z.string().trim().max(60)), + district: clearable(z.string().trim().max(60)), + address: clearable(z.string().trim().max(300)), + price: clearable(z.coerce.number().nonnegative()), + currency: z.enum(CURRENCIES), + rooms: clearable(z.coerce.number().int().min(0).max(50)), + area: clearable(z.coerce.number().nonnegative()), + floor: clearable(z.coerce.number().int().min(-5).max(200)), + floorsTotal: clearable(z.coerce.number().int().min(0).max(200)), + authorName: clearable(z.string().trim().max(100)), +}).partial(); + +export function registerLeadRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher, source?: PagedSource | null, groupSender?: GroupSender) { + const actor = (ctx: MemberCtx): Actor => ctx; + + /** Телефон автора виден ответственному и руководителю; у лида в пуле и у чужого лида он скрыт. */ + const present = (ctx: MemberCtx, lead: Lead, assigneeName?: string | null) => ({ + ...lead, + phone: ctx.role === 'manager' || lead.assigneeId === ctx.user.id ? lead.phone : null, + phoneHidden: Boolean(lead.phone) && ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id, + assigneeName: assigneeName ?? null, + }); + + // --- список лидов --- + + app.get('/api/leads', async (req) => { + const ctx = g.member(req); + const q = z + .object({ + tab: z.enum(['pool', 'mine', 'all']).default('pool'), + statusId: z.coerce.number().int().optional(), + assigneeId: z.coerce.number().int().optional(), + q: z.string().trim().max(100).optional(), + district: z.string().trim().max(60).optional(), + /** Несколько районов через запятую. */ + districts: z.string().max(1000).optional(), + priceFrom: z.coerce.number().nonnegative().optional(), + priceTo: z.coerce.number().nonnegative().optional(), + /** В какой валюте заданы границы цены: цены объектов пересчитываются по курсу. */ + priceCurrency: z.enum(['AMD', 'USD']).default('USD'), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + /** Только избранные объекты пользователя. */ + favorite: z.literal('1').optional(), + /** Только объекты, карточку которых пользователь ещё не открывал. */ + unseen: z.literal('1').optional(), + /** Руководителю — один отдел; none — объекты без отдела. */ + departmentId: z.union([z.literal('none'), z.coerce.number().int().positive()]).optional(), + }) + .parse(req.query); + const seeAll = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll; + if (q.tab === 'all' && !seeAll) throw forbidden(); + + const fresh = await statusByCode(db, ctx.company.id, 'new'); + const where: (SQL | undefined)[] = [ + eq(leads.companyId, ctx.company.id), + // Сотрудник видит только объекты своего отдела. + departmentScope(actor(ctx), leads.departmentId), + // Снятые с List.am и закрытые сделкой или отказом объекты в рабочие списки не попадают — они в «Базе». + leadActual, + ]; + if (q.departmentId !== undefined && ctx.role === 'manager') where.push(inDepartment(leads.departmentId, q.departmentId === 'none' ? null : q.departmentId)); + if (q.favorite) where.push(sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`); + if (q.unseen) where.push(sql`not exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`); + if (q.tab === 'pool') where.push(isNull(leads.assigneeId), eq(leads.statusId, fresh.id)); + if (q.tab === 'mine') where.push(eq(leads.assigneeId, ctx.user.id)); + if (q.statusId) where.push(eq(leads.statusId, q.statusId)); + if (q.assigneeId && q.tab === 'all') where.push(eq(leads.assigneeId, q.assigneeId)); + if (q.district) where.push(ilike(leads.district, q.district)); + const districts = q.districts?.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean) ?? []; + if (districts.length) where.push(inArray(sql`lower(${leads.district})`, districts)); + if (q.priceFrom !== undefined || q.priceTo !== undefined) { + const { rates } = await loadBillingSettings(db); + where.push(priceRange(leads.price, leads.currency, rates, q.priceCurrency, q.priceFrom, q.priceTo)); + } + if (q.from) where.push(gte(leads.createdAt, q.from)); + if (q.to) where.push(lte(leads.createdAt, q.to)); + if (q.q) { + // Поиск по телефону, адресу, названию и ID объявления. + const like = `%${q.q.replace(/[%_\\]/g, '\\$&')}%`; + const digits = q.q.replace(/\D/g, ''); + where.push( + or( + ilike(leads.address, like), + ilike(leads.title, like), + ilike(leads.url, like), + // По телефону агент находит только свои лиды — чужие номера скрыты. + digits.length >= 4 + ? and(ilike(leads.phone, `%${digits}%`), ctx.role === 'manager' ? undefined : eq(leads.assigneeId, ctx.user.id)) + : undefined, + ), + ); + } + const rows = await db + .select({ + lead: leads, + assigneeName: users.name, + departmentName: departments.name, + favorite: sql`exists (select 1 from lead_favorites lf where lf.lead_id = "leads"."id" and lf.user_id = ${ctx.user.id})`, + seen: sql`exists (select 1 from lead_views lv where lv.lead_id = "leads"."id" and lv.user_id = ${ctx.user.id})`, + }) + .from(leads) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .leftJoin(departments, eq(departments.id, leads.departmentId)) + .where(and(...where)) + // Лид, чьё объявление подняли на list.am, встаёт наверх. + .orderBy(desc(sql`coalesce(${leads.bumpedAt}, ${leads.createdAt})`), desc(leads.id)) + .limit(300); + return rows.map((r) => ({ ...present(ctx, r.lead, r.assigneeName), departmentName: r.departmentName, favorite: r.favorite, seen: r.seen })); + }); + + /** Курсы валют (сколько единиц в одном долларе): фильтр цены пересчитывает границы при смене валюты. */ + app.get('/api/rates', async (req) => { + g.member(req); + const { rates } = await loadBillingSettings(db); + return { USD: 1, ...rates }; + }); + + app.get('/api/leads/:id', async (req) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const lead = await loadLead(db, actor(ctx), id); + // Карточку открыли — объект больше не «непросмотренный» для этого пользователя. + await db + .insert(leadViews) + .values({ userId: ctx.user.id, leadId: id }) + .onConflictDoUpdate({ target: [leadViews.userId, leadViews.leadId], set: { viewedAt: new Date() } }); + const [[favorite], [department]] = await Promise.all([ + db.select({ id: leadFavorites.leadId }).from(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))), + lead.departmentId ? db.select({ name: departments.name }).from(departments).where(eq(departments.id, lead.departmentId)) : [], + ]); + const [history, assignee, previous, interested, deal, listing] = await Promise.all([ + db + .select({ event: events, userName: users.name }) + .from(events) + .leftJoin(users, eq(users.id, events.userId)) + .where(eq(events.leadId, id)) + .orderBy(desc(events.at), desc(events.id)), + lead.assigneeId ? db.select({ name: users.name }).from(users).where(eq(users.id, lead.assigneeId)) : [], + lead.previousLeadId + ? db + .select({ lead: leads, assigneeName: users.name, status: pipelineStatuses.name, removedAt: listings.removedAt }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .leftJoin(listings, eq(listings.id, leads.listingId)) + .where(and(eq(leads.id, lead.previousLeadId), eq(leads.companyId, ctx.company.id))) + : [], + db + .select({ client: clients, state: clientLeads.state, assigneeName: users.name }) + .from(clientLeads) + .innerJoin(clients, eq(clients.id, clientLeads.clientId)) + .leftJoin(users, eq(users.id, clients.assigneeId)) + .where(eq(clientLeads.leadId, id)) + .orderBy(desc(clientLeads.createdAt)), + db + .select({ deal: deals, clientName: clients.name }) + .from(deals) + .leftJoin(clients, eq(clients.id, deals.clientId)) + .where(eq(deals.leadId, id)), + lead.listingId ? db.select({ active: listings.active, removedAt: listings.removedAt }).from(listings).where(eq(listings.id, lead.listingId)) : [], + ]); + const prev = previous[0]; + const seesClients = ctx.role === 'manager' || ctx.company.settings.agentsSeeAll; + const d = deal[0]; + // В событиях назначения хранятся ID агентов — подставляем имена. + const names = new Map( + (await db.select({ id: users.id, name: users.name }).from(users).where(eq(users.companyId, ctx.company.id))).map( + (u) => [String(u.id), u.name] as const, + ), + ); + const byId = (v: string | null) => (v ? (names.get(v) ?? v) : null); + // Полный набор фото и текст для клиента готовятся в фоне: открытие карточки ставит объект в очередь. + const fromListing = Boolean(lead.listingId); + if (fromListing && !lead.galleryAt && !lead.galleryWantedAt && lead.galleryTries < 3) { + await db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, id)); + } + return { + ...present(ctx, lead, assignee[0]?.name), + favorite: Boolean(favorite), + departmentName: department?.name ?? null, + // Фото с List.am отдаёт сам сервис — обрезанными сверху, как на странице клиента. + photos: lead.photos.slice(0, 20).map((src, i) => (/^https:\/\/(?:img|s)\.list\.am\//.test(src) ? `/api/leads/${id}/photos/${i}` : src)), + /** ready — все фото загружены; pending — догружаются; none — объект заведён вручную или загрузить не удалось. */ + gallery: lead.galleryAt ? 'ready' : fromListing && lead.galleryTries < 3 ? 'pending' : 'none', + /** Комиссия агентства с собственника: условие и ожидаемая сумма от цены объекта. */ + commission: commissionView(ctx.company.settings.commission, lead.dealType === 'rent' ? 'objectRent' : 'objectSale', lead.commission, lead.price, lead.currency), + /** Формулировка в объявлении, по которой видно посредника или собственника против агентов. */ + agentPhrase: agentPhrase(`${lead.title}\n${lead.description ?? ''}`), + /** Текст для клиента: карточка по шаблону, без адреса. */ + clientText: lead.showcaseText ?? renderShowcaseText(lead), + // Варианты номера из общей базы видит тот же, кто видит сам телефон: ответственный и руководитель. + phoneVariants: lead.listingId && (ctx.role === 'manager' || lead.assigneeId === ctx.user.id) ? await phoneVariants(db, lead.listingId) : [], + // Заинтересованные клиенты: агент видит своих (и клиентов своего отдела при открытой видимости), руководитель — всех. + clients: interested + .filter((r) => ctx.role === 'manager' || r.client.assigneeId === ctx.user.id || (seesClients && sameDepartment(r.client.departmentId, ctx.user.departmentId))) + .map((r) => ({ id: r.client.id, name: r.client.name, kind: r.client.kind, statusId: r.client.statusId, state: r.state, assigneeName: r.assigneeName })), + deal: d ? { ...dealView(d.deal, names), clientId: d.deal.clientId, clientName: d.clientName } : null, + // Объявление сняли с List.am (проверка раз в сутки). + removedAt: listing[0]?.active === false ? listing[0].removedAt : null, + // «Уже был в работе»: итог прошлой карточки виден всем, ссылка на неё — тем, кому она доступна. + previous: prev + ? { + id: canSee(actor(ctx), prev.lead) ? prev.lead.id : null, + title: prev.lead.title, assigneeName: prev.assigneeName, status: prev.status, removedAt: prev.removedAt, + } + : null, + events: history.map(({ event: e, userName }) => ({ + id: e.id, + type: e.type, + at: e.at, + userName, + oldValue: e.type === 'assigned' || e.type === 'pool_return' ? byId(e.oldValue) : e.oldValue, + newValue: e.type === 'assigned' ? byId(e.newValue) : e.newValue, + text: e.text, + })), + }; + }); + + // --- действия с лидом --- + + app.post('/api/leads', async (req) => { + const ctx = g.writable(g.member(req)); + const lead = await createManualLead(db, actor(ctx), ManualLeadBody.parse(req.body)); + return { id: lead.id }; + }); + + /** + * Объект по ссылке на list.am: есть в CRM и свободен — закрепляется за агентом; закреплён за + * другим — ответ с его именем; нет в CRM — объявление скачивается с list.am и заводится на агента. + */ + app.post('/api/leads/by-url', async (req) => { + const ctx = g.writable(g.member(req)); + const { url } = z.object({ url: z.string().trim().max(1000) }).parse(req.body); + const externalId = /list\.am\/(?:[a-z]{2}\/)?item\/(\d{4,15})/i.exec(url)?.[1]; + if (!externalId) throw badRequest('BAD_LISTAM_URL'); + const [known] = await db.select().from(listings).where(eq(listings.externalId, externalId)); + // Сотрудник ищет объект в своём отделе: в другом отделе у объекта своя карточка. + const found = await findListingLead(db, ctx.company.id, externalId, known?.id ?? null, ctx.role === 'manager' ? undefined : ctx.user.departmentId); + if (found) return claimFoundLead(db, actor(ctx), found); + let listing = known; + if (!listing) { + if (!source?.item) throw badRequest('PARSER_DISABLED'); + const fetched = await importListing(db, source, externalId).catch((error: unknown) => { + console.error('[by-url]', error); + throw badRequest('LISTAM_UNAVAILABLE'); + }); + if (!fetched) throw notFound('LISTAM_NOT_FOUND'); + listing = fetched; + } + return createClaimedLead(db, actor(ctx), listing); + }); + + /** «Отправить в Telegram»: объект сразу уходит в группу компании её ботом. */ + app.post('/api/leads/:id/group', async (req) => { + const ctx = g.writable(g.member(req)); + const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id); + await sendLeadToGroup({ db, config, sender: groupSender, fetchImage }, ctx.company, lead); + return { ok: true }; + }); + + /** «Отправить в бот»: объект уходит основным ботом в личный чат того, кто нажал кнопку. */ + app.post('/api/leads/:id/bot', async (req) => { + const ctx = g.member(req); + const lead = await loadLead(db, actor(ctx), Id.parse(req.params).id); + await sendLeadToUser({ db, config, sender: groupSender, fetchImage }, ctx.company, lead, ctx.user); + return { ok: true }; + }); + + /** Избранное: у каждого пользователя своё. */ + app.post('/api/leads/:id/favorite', async (req) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const { on } = z.object({ on: z.boolean() }).parse(req.body); + await loadLead(db, actor(ctx), id); + if (on) await db.insert(leadFavorites).values({ userId: ctx.user.id, leadId: id }).onConflictDoNothing(); + else await db.delete(leadFavorites).where(and(eq(leadFavorites.userId, ctx.user.id), eq(leadFavorites.leadId, id))); + return { ok: true, favorite: on }; + }); + + /** Удалить карточку объекта: руководитель; со сделкой — только суперадмин. */ + app.delete('/api/leads/:id', async (req) => { + const ctx = g.writable(g.member(req)); + await deleteLead(db, actor(ctx), Id.parse(req.params).id); + return { ok: true }; + }); + + app.post('/api/leads/:id/take', async (req) => { + const ctx = g.writable(g.member(req)); + const lead = await takeLead(db, actor(ctx), Id.parse(req.params).id); + return present(ctx, lead, ctx.user.name); + }); + + app.post('/api/leads/:id/assign', async (req) => { + const ctx = g.writable(g.manager(req)); + const { userId } = z.object({ userId: z.number().int().positive() }).parse(req.body); + await assignLead(db, actor(ctx), Id.parse(req.params).id, userId); + return { ok: true }; + }); + + app.post('/api/leads/:id/release', async (req) => { + const ctx = g.writable(g.member(req)); + await releaseLead(db, actor(ctx), Id.parse(req.params).id); + return { ok: true }; + }); + + app.post('/api/leads/:id/status', async (req) => { + const ctx = g.writable(g.member(req)); + const body = z + .object({ + statusId: z.number().int().positive(), + rejectReasonId: z.number().int().positive().optional(), + deal: DealBody.extend({ clientId: z.number().int().positive().optional() }).optional(), + }) + .parse(req.body); + await changeStatus(db, actor(ctx), Id.parse(req.params).id, body); + return { ok: true }; + }); + + app.post('/api/leads/:id/comments', async (req) => { + const ctx = g.writable(g.member(req)); + const { text } = z.object({ text: z.string().trim().min(1).max(5000) }).parse(req.body); + await addComment(db, actor(ctx), Id.parse(req.params).id, text); + return { ok: true }; + }); + + app.post('/api/leads/:id/next-action', async (req) => { + const ctx = g.writable(g.member(req)); + const body = z + .object({ at: z.coerce.date().nullable(), note: z.string().trim().max(500).nullable().default(null) }) + .parse(req.body); + await setNextAction(db, actor(ctx), Id.parse(req.params).id, body.at, body.note || null); + return { ok: true }; + }); + + /** Условие комиссии для этого объекта; null возвращает условие из настроек компании. */ + app.post('/api/leads/:id/commission', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { rule } = z.object({ rule: CommissionRule.nullable() }).parse(req.body); + const lead = await loadLead(db, actor(ctx), id); + if (ctx.role !== 'manager' && lead.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_LEAD'); + const before = ruleCode(lead.commission); + const after = ruleCode(rule); + if (before === after) return { ok: true }; + await db.transaction(async (tx) => { + await tx.update(leads).set({ commission: rule }).where(eq(leads.id, id)); + // В истории видно, кто и какое условие поставил; «было» — то, что действовало до правки, включая условие компании. + const settings = ctx.company.settings.commission?.[lead.dealType === 'rent' ? 'objectRent' : 'objectSale']; + await tx.insert(events).values({ + companyId: ctx.company.id, leadId: id, userId: ctx.user.id, type: 'commission', + oldValue: before ?? ruleCode(settings), newValue: after, + // Вместе с условием сохраняется, на сколько процентов изменилась ожидаемая сумма. + text: commissionNote(after ? null : ruleCode(settings), commissionDelta(lead.commission ?? settings, rule ?? settings, lead.price, lead.currency)), + }); + }); + return { ok: true }; + }); + + /** Телефон собственника: агент берёт его из объявления и вносит сам. */ + app.post('/api/leads/:id/phone', async (req) => { + const ctx = g.writable(g.member(req)); + const { phone } = z.object({ phone: z.string().trim().max(40) }).parse(req.body); + await setLeadPhone(db, actor(ctx), Id.parse(req.params).id, phone); + return { ok: true }; + }); + + /** Фото объекта, обрезанное сверху. */ + app.get('/api/leads/:id/photos/:index', async (req, reply) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const { index } = z.object({ index: z.coerce.number().int().min(0).max(50) }).parse(req.params); + const lead = await loadLead(db, actor(ctx), id); + const url = lead.photos[index]; + if (!url || !/^https:\/\/(?:img|s)\.list\.am\//.test(url)) throw notFound(); + const photo = await croppedPhoto(config, url, fetchImage).catch(() => { + throw notFound(); + }); + return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=604800').send(photo); + }); + + app.post('/api/leads/:id/agent', async (req) => { + const ctx = g.writable(g.member(req)); + await markAsAgent(db, actor(ctx), Id.parse(req.params).id); + return { ok: true }; + }); + + /** Правка данных объекта: те же поля, что у объекта вручную, кроме телефона и ссылки. */ + app.patch('/api/leads/:id', async (req) => { + const ctx = g.writable(g.member(req)); + const body = onlySent(req.body, LeadEditBody.parse(req.body)); + if (!Object.keys(body).length) throw badRequest('VALIDATION'); + await editLead(db, actor(ctx), Id.parse(req.params).id, body); + return { ok: true }; + }); + + app.post('/api/leads/:id/removed', async (req) => { + const ctx = g.writable(g.member(req)); + await markListingRemoved(db, actor(ctx), Id.parse(req.params).id); + return { ok: true }; + }); + + // --- фильтры --- + + /** + * Агент видит фильтры своего отдела (без отдела — общие фильтры компании) и свои личные; + * руководитель — все фильтры: компании, отделов и личные фильтры агентов. + */ + app.get('/api/filters', async (req) => { + const ctx = g.member(req); + const rows = await db + .select({ filter: filters, ownerName: users.name, departmentName: departments.name }) + .from(filters) + .leftJoin(users, eq(users.id, filters.ownerUserId)) + .leftJoin(departments, eq(departments.id, filters.departmentId)) + .where( + and( + eq(filters.companyId, ctx.company.id), + ctx.role === 'manager' + ? undefined + : or(and(isNull(filters.ownerUserId), inDepartment(filters.departmentId, ctx.user.departmentId)), eq(filters.ownerUserId, ctx.user.id)), + ), + ) + .orderBy(asc(filters.id)); + return rows.map(({ filter: f, ownerName, departmentName }) => ({ + id: f.id, + name: f.name, + enabled: f.enabled, + params: f.params, + scope: f.ownerUserId !== null ? 'personal' : f.departmentId !== null ? 'department' : 'company', + ownerUserId: f.ownerUserId, + ownerName, + departmentId: f.departmentId, + departmentName, + // Фильтр компании и отдела меняет руководитель, личный — его владелец. + editable: f.ownerUserId === null ? ctx.role === 'manager' : f.ownerUserId === ctx.user.id, + })); + }); + + const FilterBody = z.object({ + name: z.string().trim().min(1).max(80), + enabled: z.boolean().default(true), + params: FilterParams, + }); + + /** Отдел компании по ID; чужой или несуществующий — ошибка. */ + const companyDepartment = async (ctx: MemberCtx, id: number) => { + const [d] = await db.select({ id: departments.id }).from(departments).where(and(eq(departments.id, id), eq(departments.companyId, ctx.company.id))); + if (!d) throw badRequest('BAD_DEPARTMENT'); + return d.id; + }; + + app.post('/api/filters', async (req) => { + const ctx = g.writable(g.member(req)); + const body = FilterBody.extend({ + scope: z.enum(['company', 'department', 'personal']), + departmentId: z.number().int().positive().optional(), + }).parse(req.body); + // Фильтры компании и отделов задаёт руководитель. + if (body.scope !== 'personal' && ctx.role !== 'manager') throw forbidden(); + if (body.scope === 'department' && !body.departmentId) throw badRequest('BAD_DEPARTMENT'); + const departmentId = body.scope === 'department' ? await companyDepartment(ctx, body.departmentId!) : null; + const [row] = await db + .insert(filters) + .values({ + companyId: ctx.company.id, + ownerUserId: body.scope === 'personal' ? ctx.user.id : null, + departmentId, + name: body.name, + enabled: body.enabled, + params: body.params, + }) + .returning(); + // Подходящие объявления из общей базы сразу попадают в пул (без уведомлений). + return { id: row!.id, fromBase: await backfillFilter(db, row!.id) }; + }); + + const ownFilter = async (ctx: MemberCtx, id: number) => { + const [f] = await db.select().from(filters).where(and(eq(filters.id, id), eq(filters.companyId, ctx.company.id))); + if (!f) throw notFound(); + if (f.ownerUserId === null ? ctx.role !== 'manager' : f.ownerUserId !== ctx.user.id) throw forbidden(); + return f; + }; + + app.patch('/api/filters/:id', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const f = await ownFilter(ctx, id); + const { departmentId, ...body } = onlySent( + req.body, + FilterBody.partial().extend({ departmentId: z.number().int().positive().nullable() }).partial().parse(req.body), + ); + // Руководитель переносит фильтр компании в отдел и обратно; у личного фильтра отдела нет. + const patch = { + ...body, + ...(departmentId !== undefined && f.ownerUserId === null + ? { departmentId: departmentId === null ? null : await companyDepartment(ctx, departmentId) } + : {}), + }; + if (!Object.keys(patch).length) throw badRequest('VALIDATION'); + await db.update(filters).set(patch).where(eq(filters.id, id)); + return { ok: true, fromBase: await backfillFilter(db, id) }; + }); + + app.delete('/api/filters/:id', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + await ownFilter(ctx, id); + await db.delete(filters).where(eq(filters.id, id)); + return { ok: true }; + }); + + /** Счётчики для вкладок списка лидов. */ + app.get('/api/leads-summary', async (req) => { + const ctx = g.member(req); + const fresh = await statusByCode(db, ctx.company.id, 'new'); + const [row] = await db + .select({ + pool: sql`count(*) filter (where ${leads.assigneeId} is null and ${leads.statusId} = ${fresh.id})::int`, + mine: sql`count(*) filter (where ${leads.assigneeId} = ${ctx.user.id})::int`, + all: sql`count(*)::int`, + }) + .from(leads) + .where(and(eq(leads.companyId, ctx.company.id), departmentScope(actor(ctx), leads.departmentId), leadActual)); + return row ?? { pool: 0, mine: 0, all: 0 }; + }); +} diff --git a/apps/api/src/leads/service.ts b/apps/api/src/leads/service.ts new file mode 100644 index 0000000..3c5f288 --- /dev/null +++ b/apps/api/src/leads/service.ts @@ -0,0 +1,792 @@ +import { normalizePhone, phoneConfidence } from '@lr/shared'; +import { and, eq, isNull, or, sql, type AnyColumn, type SQL } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { + blacklist, + companies, + deals, + events, + leads, + listingPhones, + listings, + pipelineStatuses, + rejectReasons, + users, +} from '../db/schema.ts'; +import { badRequest, conflict, forbidden, notFound } from '../lib/errors.ts'; +import { dealInput, dealText, dropDealSide, moveClientToDeal, recordDeal, type DealBody, type DealInput } from '../deals/service.ts'; +import { statusByCode } from '../pipelines/service.ts'; +import { enqueueLead, enqueueTaken, enqueueText } from '../telegram/outbox.ts'; + +// Жизненный цикл лида в воронке объекта: пул → «Беру» → статусы → сделка или отказ, +// возврат в пул. Каждое действие пишет событие в историю и ставит уведомления в очередь. + +export type Lead = typeof leads.$inferSelect; +export type User = typeof users.$inferSelect; +type Company = typeof companies.$inferSelect; +type Listing = typeof listings.$inferSelect; + +/** Кто действует: пользователь компании с ролью в текущем кабинете. */ +export interface Actor { + user: User; + role: 'manager' | 'agent' | 'superadmin'; + company: Company; +} + +const isManager = (a: Actor) => a.role === 'manager'; + +/** Один и тот же отдел: пустой отдел («без отдела») равен только пустому. */ +export const sameDepartment = (a: number | null | undefined, b: number | null | undefined) => (a ?? null) === (b ?? null); + +/** Условие «запись из отдела department» для запросов; null — записи без отдела. */ +export const inDepartment = (column: AnyColumn, department: number | null | undefined): SQL => + department == null ? isNull(column) : eq(column, department); + +/** + * Какие отделы видит пользователь в списках: руководитель и суперадмин — все (undefined — без ограничения), + * сотрудник — только свой. + */ +export const departmentScope = (actor: Actor, column: AnyColumn): SQL | undefined => + isManager(actor) ? undefined : inDepartment(column, actor.user.departmentId); + +/** + * Объект актуален: объявление не снято с List.am (у объекта вручную объявления нет) и карточка не закрыта + * сделкой или отказом. Неактуальные объекты остаются в базе и в разделе «База», но не попадают в рабочие + * списки, поиск, фильтры и подборки. + */ +// Столбцы внешней таблицы в подзапросах — с явным именем таблицы: без него Postgres может взять столбец подзапроса. +export const listingLive = sql`not exists (select 1 from listings li where li.id = "leads"."listing_id" and not li.active)`; +export const leadOpen = sql`not exists (select 1 from pipeline_statuses ps where ps.id = "leads"."status_id" and ps.code in ('deal', 'rejected'))`; +export const leadActual = sql`(${listingLive} and ${leadOpen})`; + +/** + * Цена в выбранной валюте не ниже from и не выше to. Цена объекта пересчитывается по курсам + * (сколько единиц валюты в одном долларе); объект без валюты считается в долларах. + */ +export function priceRange( + price: AnyColumn, + currency: AnyColumn, + rates: Record, + target: string, + from?: number, + to?: number, +): SQL | undefined { + if (from === undefined && to === undefined) return undefined; + const perUsd = (code: string) => rates[code] ?? (code === 'USD' ? 1 : undefined); + const known = Object.entries({ USD: 1, ...rates }).filter(([, rate]) => rate > 0); + // Цена объекта в долларах: делим на курс его валюты. + const usd = sql`(${price}::numeric / (case coalesce(${currency}, 'USD') ${sql.join( + known.map(([code, rate]) => sql`when ${code} then ${rate}::numeric`), + sql` `, + )} else 1 end))`; + const k = perUsd(target) ?? 1; + return and( + sql`${price} is not null`, + from !== undefined ? sql`${usd} >= ${from / k}::numeric` : undefined, + to !== undefined ? sql`${usd} <= ${to / k}::numeric` : undefined, + ); +} + +export const activeMembers = (tx: Tx, companyId: number) => + tx + .select() + .from(users) + .where(and(eq(users.companyId, companyId), isNull(users.removedAt))); + +async function logEvent( + tx: Tx, + lead: Pick, + userId: number | null, + type: string, + extra: { oldValue?: string | null; newValue?: string | null; text?: string | null; statusId?: number } = {}, +) { + await tx.insert(events).values({ companyId: lead.companyId, leadId: lead.id, userId, type, ...extra }); +} + +const touchCompany = (tx: Tx, companyId: number) => + tx.update(companies).set({ lastActivityAt: new Date() }).where(eq(companies.id, companyId)); + +/** + * Может ли пользователь видеть лид. Руководитель — любой. Сотрудник — только лиды своего отдела: + * свой, из пула, либо любой при открытой видимости. + */ +export function canSee(actor: Actor, lead: Lead): boolean { + if (lead.companyId !== actor.company.id) return false; + if (isManager(actor)) return true; + if (!sameDepartment(lead.departmentId, actor.user.departmentId)) return false; + if (actor.company.settings.agentsSeeAll) return true; + return lead.assigneeId === actor.user.id || lead.assigneeId === null; +} + +/** Вести лид (статус, комментарий, напоминание) может ответственный и руководитель. */ +function assertCanWork(actor: Actor, lead: Lead) { + if (!isManager(actor) && lead.assigneeId !== actor.user.id) throw forbidden('NOT_YOUR_LEAD'); +} + +async function loadLead(tx: Tx, actor: Actor, leadId: number): Promise { + const [lead] = await tx + .select() + .from(leads) + .where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id))); + if (!lead || !canSee(actor, lead)) throw notFound('LEAD_NOT_FOUND'); + return lead; +} + +const copyProperty = (l: Listing) => ({ + title: l.title, + description: l.description, + dealType: l.dealType, + propertyType: l.propertyType, + region: l.region, + district: l.district, + address: l.address, + price: l.price, + currency: l.currency, + rooms: l.rooms, + area: l.area, + floor: l.floor, + floorsTotal: l.floorsTotal, + photos: l.photos, + authorName: l.authorName, + phone: l.phone, + features: l.features, +}); + +/** + * Лид из объявления: статус «Новый», в пуле, уведомления подходящим агентам. + * Возвращает null, если у компании уже есть лид по этому объявлению. + */ +export async function createLeadFromListing( + tx: Tx, + companyId: number, + listing: Listing, + recipients: User[], + /** Прошлый лид по тому же объекту: объявление сняли и опубликовали снова. */ + previousLeadId: number | null = null, + /** Отдел, в который пришёл объект; null — без отдела. */ + departmentId: number | null = null, +) { + const fresh = await statusByCode(tx, companyId, 'new'); + const [lead] = await tx + .insert(leads) + .values({ companyId, departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: fresh.id, previousLeadId, bumpedAt: listing.bumpedAt, ...copyProperty(listing) }) + .onConflictDoNothing() + .returning(); + if (!lead) return null; + await logEvent(tx, lead, null, 'created', { newValue: 'listam' }); + await enqueueLead(tx, lead.id, recipients); + return lead; +} + +export interface ManualLead { + title: string; + description?: string; + dealType?: string; + propertyType?: string; + region?: string; + district?: string; + address?: string; + price?: number; + currency?: string; + rooms?: number; + area?: number; + floor?: number; + floorsTotal?: number; + authorName?: string; + phone?: string; + url?: string; +} + +/** Лид вручную (объект не с List.am): сразу закреплён за создателем в статусе «Взят в работу». */ +export async function createManualLead(db: Db, actor: Actor, input: ManualLead) { + return db.transaction(async (tx) => { + const taken = await statusByCode(tx, actor.company.id, 'taken'); + const [lead] = await tx + .insert(leads) + .values({ + ...input, + phone: normalizePhone(input.phone) ?? input.phone ?? null, + photos: [], + companyId: actor.company.id, + // Объект попадает в отдел того, кто его завёл. + departmentId: actor.user.departmentId, + source: 'manual', + statusId: taken.id, + assigneeId: actor.user.id, + takenAt: new Date(), + createdById: actor.user.id, + }) + .returning(); + await logEvent(tx, lead!, actor.user.id, 'created', { newValue: 'manual' }); + await touchCompany(tx, actor.company.id); + return lead!; + }); +} + +/** Чем кончилось добавление объекта ссылкой на list.am. */ +export type ClaimResult = + /** Объект был в пуле — теперь он закреплён за агентом. */ + | { result: 'taken'; id: number } + /** Объекта в CRM не было — заведён и закреплён за агентом. */ + | { result: 'created'; id: number } + /** Объект уже у этого агента. */ + | { result: 'mine'; id: number } + /** Объект закреплён за другим агентом; id — если агенту можно открыть карточку. */ + | { result: 'busy'; id: number | null; assigneeName: string | null }; + +/** + * Лид компании по объявлению list.am: последний по связи с общей базой или по ссылке у объекта, + * заведённого вручную. `department` — искать только в этом отделе (undefined — во всей компании). + */ +export async function findListingLead(tx: Tx, companyId: number, externalId: string, listingId: number | null, department?: number | null) { + const byUrl = sql`${leads.url} ~ ${`list\\.am/(?:[a-z]{2}/)?item/${externalId}([^0-9]|$)`}`; + const [row] = await tx + .select({ lead: leads, assigneeName: users.name }) + .from(leads) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .where( + and( + eq(leads.companyId, companyId), + department === undefined ? undefined : inDepartment(leads.departmentId, department), + listingId === null ? byUrl : or(eq(leads.listingId, listingId), byUrl), + ), + ) + .orderBy(sql`${leads.id} desc`) + .limit(1); + return row ?? null; +} + +/** + * Агент добавил ссылкой объект, который уже есть в CRM: свободный объект из пула закрепляется + * за ним, иначе он узнаёт, за кем объект закреплён. + */ +export async function claimFoundLead(db: Db, actor: Actor, found: { lead: Lead; assigneeName: string | null }): Promise { + const { lead } = found; + if (lead.assigneeId === actor.user.id) return { result: 'mine', id: lead.id }; + if (lead.assigneeId === null) { + const fresh = await statusByCode(db, actor.company.id, 'new'); + if (lead.statusId === fresh.id) { + try { + return { result: 'taken', id: (await takeLead(db, actor, lead.id)).id }; + } catch (error) { + // Объект успели взять, пока агент вставлял ссылку: показываем, кто взял. + if (!(error instanceof Error && 'code' in error && error.code === 'ALREADY_TAKEN')) throw error; + const [again] = await db.select({ lead: leads, assigneeName: users.name }).from(leads).leftJoin(users, eq(users.id, leads.assigneeId)).where(eq(leads.id, lead.id)); + return claimFoundLead(db, actor, again!); + } + } + } + return { result: 'busy', id: canSee(actor, lead) ? lead.id : null, assigneeName: found.assigneeName }; +} + +/** Объекта ещё нет в CRM компании: лид из объявления сразу закреплён за агентом, без пула и рассылки. */ +export async function createClaimedLead(db: Db, actor: Actor, listing: Listing): Promise { + const lead = await db.transaction(async (tx) => { + const taken = await statusByCode(tx, actor.company.id, 'taken'); + const [row] = await tx + .insert(leads) + .values({ + companyId: actor.company.id, departmentId: actor.user.departmentId, listingId: listing.id, source: 'listam', url: listing.url, statusId: taken.id, + assigneeId: actor.user.id, takenAt: new Date(), createdById: actor.user.id, bumpedAt: listing.bumpedAt, ...copyProperty(listing), + }) + .onConflictDoNothing() + .returning(); + if (!row) return null; + await logEvent(tx, row, actor.user.id, 'created', { newValue: 'link' }); + await touchCompany(tx, actor.company.id); + return row; + }); + if (lead) return { result: 'created', id: lead.id }; + // Лид по этому объявлению появился одновременно (парсер или другой агент). + const found = await findListingLead(db, actor.company.id, listing.externalId, listing.id, actor.user.departmentId); + return claimFoundLead(db, actor, found!); +} + +/** + * Удаление карточки объекта вместе с историей. Удаляет руководитель (и суперадмин в кабинете + * компании); карточку со сделкой — только суперадмин: сделка уходит из аналитики, ведомости и счетов. + */ +export async function deleteLead(db: Db, actor: Actor, leadId: number) { + if (!isManager(actor)) throw forbidden(); + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + const [deal] = await tx.select({ id: deals.id }).from(deals).where(eq(deals.leadId, lead.id)); + if (deal) { + if (actor.user.role !== 'superadmin') throw forbidden('HAS_DEAL'); + await dropDealSide(tx, 'lead', lead.id); + } + await tx.update(leads).set({ previousLeadId: null }).where(eq(leads.previousLeadId, lead.id)); + await tx.delete(events).where(eq(events.leadId, lead.id)); + await tx.delete(leads).where(eq(leads.id, lead.id)); + await touchCompany(tx, actor.company.id); + }); +} + +/** + * «Беру». Лид получает ровно один агент: условие «ответственного нет» проверяется самим + * UPDATE, поэтому при одновременном нажатии второй запрос не найдёт строку и получит «Уже взят». + */ +export async function takeLead(db: Db, actor: Actor, leadId: number): Promise { + return db.transaction(async (tx) => { + const fresh = await statusByCode(tx, actor.company.id, 'new'); + const taken = await statusByCode(tx, actor.company.id, 'taken'); + const [lead] = await tx + .update(leads) + .set({ + assigneeId: actor.user.id, + statusId: taken.id, + takenAt: new Date(), + statusChangedAt: new Date(), + poolWarnedAt: null, + }) + .where( + and( + eq(leads.id, leadId), + eq(leads.companyId, actor.company.id), + // Объект чужого отдела взять нельзя. + departmentScope(actor, leads.departmentId), + isNull(leads.assigneeId), + eq(leads.statusId, fresh.id), + ), + ) + .returning(); + if (!lead) { + const [exists] = await tx + .select({ id: leads.id }) + .from(leads) + .where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id), departmentScope(actor, leads.departmentId))); + throw exists ? conflict('ALREADY_TAKEN') : notFound('LEAD_NOT_FOUND'); + } + await logEvent(tx, lead, actor.user.id, 'taken'); + await enqueueTaken(tx, lead.id, actor.user.id); + await touchCompany(tx, actor.company.id); + return lead; + }); +} + +/** Руководитель назначает или переназначает лид. Оба агента получают уведомление. */ +export async function assignLead(db: Db, actor: Actor, leadId: number, toUserId: number): Promise { + if (!isManager(actor)) throw forbidden(); + return db.transaction(async (tx) => { + const [before] = await tx + .select() + .from(leads) + .where(and(eq(leads.id, leadId), eq(leads.companyId, actor.company.id))) + .for('update'); + if (!before) throw notFound('LEAD_NOT_FOUND'); + const [target] = await tx + .select() + .from(users) + .where(and(eq(users.id, toUserId), eq(users.companyId, actor.company.id), isNull(users.removedAt))); + if (!target) throw badRequest('BAD_ASSIGNEE'); + if (before.assigneeId === toUserId) return before; + // Объект переходит в отдел нового ответственного — иначе тот его не увидит. Если в том отделе уже + // есть карточка по этому объявлению, вторую туда не переносим. + const moving = !sameDepartment(before.departmentId, target.departmentId); + if (moving && before.listingId && before.previousLeadId === null) { + const [twin] = await tx + .select({ id: leads.id }) + .from(leads) + .where( + and( + eq(leads.companyId, actor.company.id), + eq(leads.listingId, before.listingId), + isNull(leads.previousLeadId), + inDepartment(leads.departmentId, target.departmentId), + ), + ); + if (twin) throw conflict('DEPARTMENT_HAS_LEAD'); + } + + const fresh = await statusByCode(tx, actor.company.id, 'new'); + const taken = await statusByCode(tx, actor.company.id, 'taken'); + const fromPool = before.assigneeId === null; + const [lead] = await tx + .update(leads) + .set({ + assigneeId: toUserId, + departmentId: target.departmentId, + poolWarnedAt: null, + statusChangedAt: new Date(), + ...(before.statusId === fresh.id ? { statusId: taken.id } : {}), + ...(fromPool ? { takenAt: new Date() } : {}), + }) + .where(eq(leads.id, leadId)) + .returning(); + await logEvent(tx, lead!, actor.user.id, 'assigned', { + oldValue: before.assigneeId ? String(before.assigneeId) : null, + newValue: String(toUserId), + }); + if (fromPool) await enqueueTaken(tx, leadId, toUserId); + if (toUserId !== actor.user.id) await enqueueText(tx, [toUserId], 'assigned', [lead!.title], leadId); + if (before.assigneeId && before.assigneeId !== actor.user.id) { + await enqueueText(tx, [before.assigneeId], 'reassignedAway', [lead!.title, target.name]); + } + await touchCompany(tx, actor.company.id); + return lead!; + }); +} + +/** + * Возврат в пул: лид снова «Новый» и без ответственного, история сохраняется. + * `actor = null` — возврат по правилу «N часов без смены статуса»: прежний агент получает + * уведомление о потере. Остальные участники получают повторное уведомление с кнопкой «Беру». + */ +export async function returnToPool(tx: Tx, lead: Lead, actorUserId: number | null): Promise { + const fresh = await statusByCode(tx, lead.companyId, 'new'); + const previous = lead.assigneeId; + await tx + .update(leads) + .set({ assigneeId: null, statusId: fresh.id, takenAt: null, statusChangedAt: new Date(), poolWarnedAt: null }) + .where(eq(leads.id, lead.id)); + await logEvent(tx, lead, actorUserId, 'pool_return', { oldValue: previous ? String(previous) : null }); + if (previous && actorUserId === null) await enqueueText(tx, [previous], 'poolLost', [lead.title]); + // «Беру» снова получают сотрудники отдела, которому принадлежит объект. + const others = (await activeMembers(tx, lead.companyId)).filter((u) => u.id !== previous && sameDepartment(u.departmentId, lead.departmentId)); + await enqueueLead(tx, lead.id, others, true); +} + +export async function releaseLead(db: Db, actor: Actor, leadId: number) { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + if (lead.assigneeId === null) throw badRequest('ALREADY_IN_POOL'); + assertCanWork(actor, lead); + await returnToPool(tx, lead, actor.user.id); + await touchCompany(tx, actor.company.id); + }); +} + +export interface StatusChange { + statusId: number; + rejectReasonId?: number; + /** Обязательна при переводе в «Сделка». */ + deal?: DealBody & { clientId?: number }; +} + +export async function changeStatus(db: Db, actor: Actor, leadId: number, change: StatusChange): Promise { + return db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + // Лид в пуле сначала нужно взять. + if (lead.assigneeId === null) throw badRequest('TAKE_FIRST'); + assertCanWork(actor, lead); + const statuses = await tx + .select() + .from(pipelineStatuses) + .where(and(eq(pipelineStatuses.companyId, actor.company.id), eq(pipelineStatuses.pipeline, 'object'))); + const from = statuses.find((s) => s.id === lead.statusId); + const to = statuses.find((s) => s.id === change.statusId && !s.deletedAt); + if (!to) throw badRequest('BAD_STATUS'); + if (to.id === lead.statusId) return lead; + // «Новый» — это пул: туда лид попадает только возвратом в пул. + if (to.code === 'new') throw badRequest('USE_RETURN_TO_POOL'); + if (from?.code === 'deal' && !isManager(actor)) throw forbidden('DEAL_CLOSED'); + + let rejectReasonId: number | null = null; + if (to.code === 'rejected') { + const [reason] = change.rejectReasonId + ? await tx + .select() + .from(rejectReasons) + .where( + and( + eq(rejectReasons.id, change.rejectReasonId), + eq(rejectReasons.companyId, actor.company.id), + eq(rejectReasons.pipeline, 'object'), + ), + ) + : []; + if (!reason) throw badRequest('REJECT_REASON_REQUIRED'); + rejectReasonId = reason.id; + } + let deal: DealInput | null = null; + if (to.code === 'deal') { + if (!change.deal) throw badRequest('COMMISSION_REQUIRED'); + deal = dealInput(change.deal, 'owner'); + await recordDeal(tx, { + companyId: actor.company.id, + leadId, + clientId: change.deal.clientId ?? null, + agentId: lead.assigneeId, + deal, + }); + // Сделка с клиентом из CRM закрывается один раз: клиент переходит в «Сделка» вместе с объектом. + if (change.deal.clientId) { + await moveClientToDeal(tx, actor.company.id, actor.user.id, leadId, change.deal.clientId, deal); + } + } else if (from?.code === 'deal') { + await dropDealSide(tx, 'lead', leadId); + } + + const [updated] = await tx + .update(leads) + .set({ statusId: to.id, rejectReasonId, statusChangedAt: new Date(), poolWarnedAt: null }) + .where(eq(leads.id, leadId)) + .returning(); + await logEvent(tx, lead, actor.user.id, to.code === 'deal' ? 'deal' : 'status', { + oldValue: from?.name ?? null, + newValue: to.name, + statusId: to.id, + text: deal ? dealText(deal) : null, + }); + await touchCompany(tx, actor.company.id); + return updated!; + }); +} + +export async function addComment(db: Db, actor: Actor, leadId: number, text: string) { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + assertCanWork(actor, lead); + await logEvent(tx, lead, actor.user.id, 'comment', { text }); + await touchCompany(tx, actor.company.id); + }); +} + +export async function setNextAction(db: Db, actor: Actor, leadId: number, at: Date | null, note: string | null) { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + assertCanWork(actor, lead); + await tx + .update(leads) + .set({ nextActionAt: at, nextActionNote: note, nextActionNotifiedAt: null }) + .where(eq(leads.id, leadId)); + }); +} + +export interface PhoneVariant { + phone: string; + /** Сколько компаний указали этот номер. */ + votes: number; + /** 80 — совпал у двух, 100 — у трёх и больше, null — указал один агент. */ + confidence: number | null; +} + +/** Все варианты номера по объявлению из общей базы: самый подтверждённый — первым. */ +export async function phoneVariants(tx: Tx, listingId: number): Promise { + const rows = await tx + .select({ phone: listingPhones.phone, votes: sql`count(*)::int`, first: sql`min(${listingPhones.createdAt})` }) + .from(listingPhones) + .where(eq(listingPhones.listingId, listingId)) + .groupBy(listingPhones.phone) + .orderBy(sql`count(*) desc`, sql`min(${listingPhones.createdAt})`); + return rows.map((r) => ({ phone: r.phone, votes: r.votes, confidence: phoneConfidence(r.votes) })); +} + +/** + * Телефон собственника, который агент взял из объявления и внёс вручную. Вариант компании + * записывается в общую базу; номером объявления становится самый подтверждённый вариант — он же + * работает в правиле «больше N объявлений с номера». Пустая строка стирает номер у лида и вариант компании. + */ +export async function setLeadPhone(db: Db, actor: Actor, leadId: number, raw: string) { + const phone = raw.trim() ? normalizePhone(raw) : null; + if (raw.trim() && !phone) throw badRequest('BAD_PHONE'); + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + assertCanWork(actor, lead); + if (lead.phone === phone) return; + await tx.update(leads).set({ phone }).where(eq(leads.id, leadId)); + if (lead.listingId) { + const mine = and(eq(listingPhones.listingId, lead.listingId), eq(listingPhones.companyId, actor.company.id)); + if (phone) { + await tx + .insert(listingPhones) + .values({ listingId: lead.listingId, companyId: actor.company.id, userId: actor.user.id, phone }) + .onConflictDoUpdate({ target: [listingPhones.listingId, listingPhones.companyId], set: { phone, userId: actor.user.id, createdAt: new Date() } }); + } else { + await tx.delete(listingPhones).where(mine); + } + const [best] = await phoneVariants(tx, lead.listingId); + await tx.update(listings).set({ phone: best?.phone ?? null }).where(eq(listings.id, lead.listingId)); + } + await logEvent(tx, lead, actor.user.id, 'phone', { newValue: phone }); + await touchCompany(tx, actor.company.id); + }); +} + +export type LeadEdit = Partial< + Pick +>; + +/** + * Правка данных объекта. Меняется только карточка компании — общая база объявлений остаётся как есть. + * Текст для клиента собирается заново из новых данных. + */ +export async function editLead(db: Db, actor: Actor, leadId: number, patch: LeadEdit) { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + assertCanWork(actor, lead); + const changed = Object.fromEntries( + Object.entries(patch).filter(([key, value]) => String(lead[key as keyof LeadEdit] ?? '') !== String(value ?? '')), + ) as LeadEdit; + if (!Object.keys(changed).length) return; + await tx.update(leads).set({ ...changed, showcaseText: null }).where(eq(leads.id, leadId)); + await logEvent(tx, lead, actor.user.id, 'edited'); + await touchCompany(tx, actor.company.id); + }); +} + +/** + * «Объявления больше нет»: агент увидел, что объявление сняли, раньше суточной проверки. + * Отметка ставится в общей базе — у всех компаний с этим объектом; если объявление снова появится + * в списке list.am, парсер вернёт его на сайт сам. + */ +export async function markListingRemoved(db: Db, actor: Actor, leadId: number) { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + if (lead.assigneeId !== null) assertCanWork(actor, lead); + if (!lead.listingId) throw badRequest('NO_LISTING'); + const now = new Date(); + const [row] = await tx + .update(listings) + .set({ active: false, removedAt: now, lastCheckedAt: now }) + .where(and(eq(listings.id, lead.listingId), eq(listings.active, true))) + .returning({ id: listings.id }); + if (!row) return; + await logEvent(tx, lead, actor.user.id, 'listing_removed'); + await rejectRemovedListingLeads(tx, row.id); + await touchCompany(tx, actor.company.id); + }); +} + +/** + * Объявление сняли с List.am: у компаний с настройкой autoRejectRemoved открытые лиды по нему + * (не «Сделка» и не «Отказ») уходят в «Отказ» с причиной «Уже продано или сдано» — в архив, + * из пула и рабочих списков. Возвращает число переведённых лидов. + */ +export async function rejectRemovedListingLeads(tx: Tx, listingId: number): Promise { + const open = await tx + .select({ lead: leads, status: pipelineStatuses.name }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .innerJoin(companies, eq(companies.id, leads.companyId)) + .where( + and( + eq(leads.listingId, listingId), + sql`coalesce(${pipelineStatuses.code}, '') not in ('deal', 'rejected')`, + sql`(${companies.settings}->>'autoRejectRemoved')::boolean is true`, + ), + ); + for (const { lead, status } of open) { + const rejected = await statusByCode(tx, lead.companyId, 'rejected'); + // У компаний, заведённых до кода 'gone', причина находится по названию. + const [reason] = await tx + .select({ id: rejectReasons.id }) + .from(rejectReasons) + .where( + and( + eq(rejectReasons.companyId, lead.companyId), + eq(rejectReasons.pipeline, 'object'), + or(eq(rejectReasons.code, 'gone'), and(isNull(rejectReasons.code), eq(rejectReasons.name, 'Уже продано или сдано'))), + ), + ) + .orderBy(sql`${rejectReasons.code} nulls last`) + .limit(1); + await tx + .update(leads) + .set({ statusId: rejected.id, rejectReasonId: reason?.id ?? null, statusChangedAt: new Date(), poolWarnedAt: null, nextActionAt: null }) + .where(eq(leads.id, lead.id)); + await logEvent(tx, lead, null, 'status', { + oldValue: status, + newValue: rejected.name, + statusId: rejected.id, + text: 'Объявление снято с List.am', + }); + } + return open.length; +} + +/** «Это агент»: номер автора — в чёрный список компании (если он известен), лид — в «Отказ». */ +export async function markAsAgent(db: Db, actor: Actor, leadId: number): Promise { + await db.transaction(async (tx) => { + const lead = await loadLead(tx, actor, leadId); + if (lead.assigneeId !== null) assertCanWork(actor, lead); + const rejected = await statusByCode(tx, actor.company.id, 'rejected'); + const [reason] = await tx + .select() + .from(rejectReasons) + .where( + and( + eq(rejectReasons.companyId, actor.company.id), + eq(rejectReasons.pipeline, 'object'), + eq(rejectReasons.code, 'agent'), + ), + ); + if (lead.phone) { + await tx + .insert(blacklist) + .values({ companyId: actor.company.id, phone: lead.phone, addedById: actor.user.id }) + .onConflictDoNothing(); + } + const wasInPool = lead.assigneeId === null; + await tx + .update(leads) + .set({ + statusId: rejected.id, + rejectReasonId: reason?.id ?? null, + assigneeId: lead.assigneeId ?? actor.user.id, + statusChangedAt: new Date(), + poolWarnedAt: null, + }) + .where(eq(leads.id, leadId)); + await logEvent(tx, lead, actor.user.id, 'blacklisted', { newValue: lead.phone, statusId: rejected.id }); + if (wasInPool) await enqueueTaken(tx, leadId, actor.user.id); + await touchCompany(tx, actor.company.id); + }); +} + +/** + * Фоновые задачи по лидам, запускаются раз в минуту: + * предупреждение за 2 часа до возврата в пул, сам возврат, напоминания о следующем действии. + */ +export async function runLeadJobs(db: Db, now = new Date()): Promise<{ warned: number; returned: number; reminded: number }> { + const result = { warned: 0, returned: 0, reminded: 0 }; + const due = await db + .select({ lead: leads, hours: sql`(${companies.settings}->>'poolReturnHours')::int` }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .innerJoin(companies, eq(companies.id, leads.companyId)) + .where( + and( + sql`${leads.assigneeId} is not null`, + eq(pipelineStatuses.poolReturn, true), + // Предупреждение уходит за 2 часа до срока; при сроке меньше 2 часов — сразу. + sql`${leads.statusChangedAt} <= ${now.toISOString()}::timestamptz - make_interval(hours => greatest((${companies.settings}->>'poolReturnHours')::int - 2, 0))`, + ), + ); + for (const { lead, hours } of due) { + const expired = lead.statusChangedAt.getTime() <= now.getTime() - hours * 3_600_000; + await db.transaction(async (tx) => { + // Перечитываем под блокировкой: агент мог сменить статус, пока шла выборка. + const [current] = await tx.select().from(leads).where(eq(leads.id, lead.id)).for('update'); + if (!current || current.assigneeId === null || current.statusChangedAt.getTime() !== lead.statusChangedAt.getTime()) return; + if (expired) { + await returnToPool(tx, current, null); + result.returned++; + } else if (!current.poolWarnedAt) { + const left = Math.max(1, Math.round((current.statusChangedAt.getTime() + hours * 3_600_000 - now.getTime()) / 3_600_000)); + await enqueueText(tx, [current.assigneeId], 'poolWarn', [current.title, left], current.id); + await tx.update(leads).set({ poolWarnedAt: now }).where(eq(leads.id, current.id)); + result.warned++; + } + }); + } + + const reminders = await db + .update(leads) + .set({ nextActionNotifiedAt: now }) + .where( + and( + sql`${leads.nextActionAt} <= ${now.toISOString()}::timestamptz`, + isNull(leads.nextActionNotifiedAt), + sql`${leads.assigneeId} is not null`, + ), + ) + .returning(); + for (const lead of reminders) { + await enqueueText(db, [lead.assigneeId!], 'reminder', [lead.title, lead.nextActionNote], lead.id); + result.reminded++; + } + return result; +} + +export { loadLead, statusByCode }; diff --git a/apps/api/src/lib/commission.ts b/apps/api/src/lib/commission.ts new file mode 100644 index 0000000..0220587 --- /dev/null +++ b/apps/api/src/lib/commission.ts @@ -0,0 +1,51 @@ +import { expectedCommission, toUsd, type CommissionKey, type CommissionRule, type CommissionSettings, type Currency } from '@lr/shared'; + +/** + * Комиссия для карточки: условие (своё или из настроек компании) и ожидаемая сумма. + * `custom` — агент поменял условие в этой карточке. + */ +export function commissionView( + settings: CommissionSettings | undefined, + key: CommissionKey, + own: CommissionRule | null, + price: number | null | undefined, + currency: string | null | undefined, +) { + const rule = own ?? settings?.[key] ?? null; + return { rule, custom: Boolean(own), expected: expectedCommission(rule, price, currency as Currency | null) }; +} + +/** Условие в записи истории: «percent:2.5», «fixed:1000:USD», «mixed:200:USD:2»; null — как в настройках компании. */ +export const ruleCode = (rule: CommissionRule | null | undefined): string | null => + !rule + ? null + : rule.mode === 'percent' + ? `percent:${rule.value}` + : rule.mode === 'fixed' + ? `fixed:${rule.value}:${rule.currency}` + : `mixed:${rule.fixed ?? 0}:${rule.currency}:${rule.value}`; + +/** + * На сколько процентов изменилась ожидаемая сумма комиссии: 50 % → 35 % от той же цены — это −30 %. + * Если цены нет, но оба условия — проценты, считаем по самим процентам. null — сравнить нечего. + */ +export function commissionDelta( + before: CommissionRule | null | undefined, + after: CommissionRule | null | undefined, + price: number | null | undefined, + currency: string | null | undefined, +): number | null { + const a = expectedCommission(before, price, currency as Currency | null); + const b = expectedCommission(after, price, currency as Currency | null); + let from: number | null = a ? toUsd(a.amount, a.currency) : null; + let to: number | null = b ? toUsd(b.amount, b.currency) : null; + if ((from === null || to === null) && before?.mode === 'percent' && after?.mode === 'percent') { + from = before.value; + to = after.value; + } + if (from === null || to === null || !(from > 0)) return null; + return Math.round(((to - from) / from) * 1000) / 10; +} + +/** Поле text записи истории: «<условие компании при сбросе>|<изменение суммы в процентах>». */ +export const commissionNote = (settingsCode: string | null, delta: number | null) => `${settingsCode ?? ''}|${delta ?? ''}`; diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts new file mode 100644 index 0000000..c25a840 --- /dev/null +++ b/apps/api/src/lib/config.ts @@ -0,0 +1,41 @@ +export interface Config { + databaseUrl: string; + port: number; + appUrl: string; + production: boolean; + telegramBotToken?: string; + telegramBotUsername?: string; + /** listam | feed | off */ + listingSource: string; + jinaApiKey?: string; + /** Клиентский бот: анкета соискателя. Без токена анкета выключена. */ + clientBotToken?: string; + clientBotUsername?: string; + /** Сколько процентов высоты срезать сверху у фото на странице клиента. */ + cropTopPercent: number; + /** Куда складывать обработанные фото. */ + mediaDir: string; + /** Переписывание текста объекта моделью через OpenRouter. Без ключа текст собирается по шаблону. */ + openRouterKey?: string; + openRouterModel: string; +} + +export function loadConfig(env = process.env): Config { + if (!env.DATABASE_URL) throw new Error('DATABASE_URL не задан'); + return { + databaseUrl: env.DATABASE_URL, + port: Number(env.API_PORT ?? 3100), + appUrl: (env.APP_URL ?? 'http://localhost:5174').replace(/\/$/, ''), + production: env.NODE_ENV === 'production', + telegramBotToken: env.TELEGRAM_BOT_TOKEN || undefined, + telegramBotUsername: env.TELEGRAM_BOT_USERNAME || undefined, + listingSource: env.LISTING_SOURCE || 'listam', + jinaApiKey: env.JINA_API_KEY || undefined, + clientBotToken: env.CLIENT_BOT_TOKEN || undefined, + clientBotUsername: env.CLIENT_BOT_USERNAME || undefined, + cropTopPercent: Math.max(0, Math.min(40, Number(env.CROP_TOP_PERCENT ?? 8) || 0)), + mediaDir: env.MEDIA_DIR || './data/media', + openRouterKey: env.OPENROUTER_API_KEY || undefined, + openRouterModel: env.OPENROUTER_MODEL || 'anthropic/claude-haiku-4.5', + }; +} diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts new file mode 100644 index 0000000..0ac3fd5 --- /dev/null +++ b/apps/api/src/lib/errors.ts @@ -0,0 +1,15 @@ +/** Ошибка с кодом: интерфейс переводит код на язык пользователя. */ +export class AppError extends Error { + constructor( + readonly status: number, + readonly code: string, + message = code, + ) { + super(message); + } +} + +export const forbidden = (code = 'FORBIDDEN') => new AppError(403, code); +export const notFound = (code = 'NOT_FOUND') => new AppError(404, code); +export const badRequest = (code: string) => new AppError(400, code); +export const conflict = (code: string) => new AppError(409, code); diff --git a/apps/api/src/lib/mailer.ts b/apps/api/src/lib/mailer.ts new file mode 100644 index 0000000..53f03a5 --- /dev/null +++ b/apps/api/src/lib/mailer.ts @@ -0,0 +1,16 @@ +export interface Mail { + to: string; + subject: string; + text: string; +} + +/** Отправка писем. Почтовый сервис ещё не выбран — пока письма пишутся в лог. */ +export interface Mailer { + send(mail: Mail): Promise; +} + +export const consoleMailer: Mailer = { + async send(mail) { + console.log(`[mail → ${mail.to}] ${mail.subject}\n${mail.text}`); + }, +}; diff --git a/apps/api/src/lib/patch.ts b/apps/api/src/lib/patch.ts new file mode 100644 index 0000000..0ae1bdf --- /dev/null +++ b/apps/api/src/lib/patch.ts @@ -0,0 +1,9 @@ +/** + * Для PATCH-запросов: оставляет из разобранного тела только поля, которые клиент действительно + * прислал. Схема с `.partial()` подставляет значения по умолчанию и в пропущенные поля — без + * этой фильтрации правка одного поля сбрасывала бы остальные. + */ +export function onlySent(body: unknown, parsed: T): Partial { + const sent = body !== null && typeof body === 'object' ? body : {}; + return Object.fromEntries(Object.entries(parsed).filter(([key]) => key in sent)) as Partial; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..fce16b2 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,99 @@ +import { buildApp } from './app.ts'; +import { runBillingJobs } from './billing/service.ts'; +import { runClientJobs } from './clients/service.ts'; +import { connect } from './db/client.ts'; +import { runIngest, runRemovalCheck } from './ingest/service.ts'; +import { runParserWatchdog, type WatchdogState } from './ingest/watchdog.ts'; +import { handleIntakeUpdate, telegramDownloader } from './intake/service.ts'; +import { runReportJobs } from './reports.ts'; +import { runShowcaseJobs } from './showcase/service.ts'; +import { runLeadJobs } from './leads/service.ts'; +import { loadConfig } from './lib/config.ts'; +import { runGroupPosts } from './group/service.ts'; +import { ListAmListingSource } from './parser/listam-source.ts'; +import { loadParserSettings } from './parser/settings.ts'; +import { startPolling } from './telegram/bot.ts'; +import { HttpTelegram } from './telegram/client.ts'; +import { processOutbox } from './telegram/outbox.ts'; + +const config = loadConfig(); +const { db } = connect(config.databaseUrl); +const source = config.listingSource === 'listam' ? new ListAmListingSource({ apiKey: config.jinaApiKey }) : null; +const app = buildApp({ db, config, source }); +await app.listen({ port: config.port, host: '0.0.0.0' }); +console.log(`API слушает порт ${config.port}`); + +/** Повторяет задачу бесконечно: следующий запуск — через паузу после окончания предыдущего. */ +function loop(name: string, pauseMs: number | (() => Promise), task: () => Promise) { + void (async () => { + for (;;) { + try { + await task(); + } catch (e) { + console.error(`[${name}]`, e); + } + const ms = typeof pauseMs === 'number' ? pauseMs : await pauseMs().catch(() => 5_000); + await new Promise((r) => setTimeout(r, ms)); + } + })(); +} + +// Клиентский бот — анкета соискателя. Без токена анкета выключена, остальное работает. +const clientTg = config.clientBotToken ? new HttpTelegram(config.clientBotToken) : null; +if (clientTg) { + const download = telegramDownloader(config.clientBotToken!); + startPolling({ tg: clientTg }, console.error, (update) => handleIntakeUpdate({ db, tg: clientTg, config, download }, update)); + console.log(`Клиентский бот @${config.clientBotUsername} запущен`); +} + +if (config.telegramBotToken) { + const tg = new HttpTelegram(config.telegramBotToken); + startPolling({ db, tg, config, clientTg }); + // Очередь сообщений: пока есть что отправлять, пачки идут подряд. + loop('outbox', 500, async () => { + while ((await processOutbox({ db, tg, config })) > 0); + }); + // Mini App открывается кнопкой меню бота; Telegram принимает для неё только https-адрес. + if (config.appUrl.startsWith('https://')) { + tg.setMenuButton('Lead Radar', config.appUrl).catch((e) => console.error('[menu-button]', e)); + } + console.log(`Бот @${config.telegramBotUsername} запущен`); +} else { + console.log('TELEGRAM_BOT_TOKEN не задан: бот и уведомления выключены'); +} + +// Возврат в пул, предупреждения и напоминания. +loop('lead-jobs', 60_000, async () => { + await runLeadJobs(db); + await runClientJobs(db); +}); + +// Объекты, которые агент показывает клиенту: полный набор фото и текст для страницы подборки. +loop('showcase', 20_000, () => runShowcaseJobs({ db, config, source })); + +// Объекты в работе с указанной комиссией — в группу компании её ботом. +loop('group', 20_000, () => runGroupPosts({ db, config })); + +// Отчёты руководителю в Telegram: за неделю и за месяц. +loop('reports', 600_000, () => runReportJobs(db, config)); + +// Счета, запланированная смена тарифа и напоминания о подписке. +loop('billing', 600_000, () => runBillingJobs(db)); + +if (source) { + // Парсер ходит по кругу почти без паузы: скорость «публикация → лид» определяется длительностью + // прохода, а темп запросов к ридеру выдерживает сам источник. + // Включение и пауза между проходами берутся из настроек парсера в админке. + loop( + 'parser', + async () => Math.max(1, (await loadParserSettings(db)).pauseSec) * 1000, + async () => { + if ((await loadParserSettings(db)).enabled) await runIngest({ db, source, log: console.log }); + }, + ); + // Отметка «Снято с List.am»: пачка объявлений раз в 10 минут, каждое — раз в сутки. + loop('removal-check', 600_000, () => runRemovalCheck({ db, source })); + // Сторож: парсер выключен или стоит дольше 20 минут — сообщение суперадминам в Telegram. + const watchdog: WatchdogState = { alertedAt: null }; + loop('parser-watchdog', 300_000, () => runParserWatchdog(db, watchdog)); +} diff --git a/apps/api/src/parser/feed-source.test.ts b/apps/api/src/parser/feed-source.test.ts new file mode 100644 index 0000000..17906af --- /dev/null +++ b/apps/api/src/parser/feed-source.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SourcedListing } from './source.ts'; +import { FeedListingSource } from './feed-source.ts'; + +const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true }; +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +async function collect(source: FeedListingSource, report = vi.fn()) { + const items: SourcedListing[] = []; + for await (const item of source.fetchLatest(filter, report)) items.push(item); + return items; +} + +describe('FeedListingSource', () => { + it('maps feed items, follows the cursor and sends the token', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce( + json({ + items: [ + { + id: 123, + url: 'https://feed.example/items/123', + title: '2-комн., Арабкир', + price: '120,000', + currency: 'usd', + district: 'Arabkir', + rooms: '2', + photos: ['https://img.example/1.jpg'], + }, + ], + next: 'c2', + }), + ) + .mockResolvedValueOnce(json({ items: [{ id: 'x9', url: 'https://feed.example/x9' }] })); + + const items = await collect( + new FeedListingSource('https://feed.example/api', 'secret', fetchFn), + ); + + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + externalId: '123', + sourceUrl: 'https://feed.example/items/123', + price: 120000, + currency: 'USD', + district: 'Arabkir', + rooms: 2, + photoUrls: ['https://img.example/1.jpg'], + }); + expect(items[1]).toMatchObject({ externalId: 'x9', title: 'Объявление x9', currency: 'USD' }); + + const [firstUrl, init] = fetchFn.mock.calls[0]!; + expect(String(firstUrl)).toBe( + 'https://feed.example/api?city=yerevan&category=apartments&ownerOnly=true', + ); + expect((init?.headers as Record).authorization).toBe('Bearer secret'); + expect(String(fetchFn.mock.calls[1]![0])).toContain('cursor=c2'); + }); + + it('skips malformed items and reports them', async () => { + const fetchFn = vi.fn().mockResolvedValue( + json({ + items: [ + { id: 1, url: 'not a url' }, + { id: 2, url: 'https://a.b/2' }, + ], + }), + ); + const report = vi.fn(); + const items = await collect( + new FeedListingSource('https://feed.example', undefined, fetchFn), + report, + ); + expect(items.map((i) => i.externalId)).toEqual(['2']); + expect(report).toHaveBeenCalledOnce(); + }); + + it('fails the run on HTTP errors', async () => { + const fetchFn = vi.fn().mockResolvedValue(json({}, 503)); + await expect( + collect(new FeedListingSource('https://feed.example', undefined, fetchFn)), + ).rejects.toThrow('Фид ответил 503'); + }); +}); diff --git a/apps/api/src/parser/feed-source.ts b/apps/api/src/parser/feed-source.ts new file mode 100644 index 0000000..400bfff --- /dev/null +++ b/apps/api/src/parser/feed-source.ts @@ -0,0 +1,141 @@ +import { CURRENCIES } from '@lr/shared'; +import { z } from 'zod'; +import type { + ListingSource, + ListingSourceFilter, + SourcedListing, +} from './source.ts'; + +const text = z + .union([z.string(), z.number()]) + .transform((v) => String(v).trim()) + .optional() + .nullable() + .transform((v) => v || undefined); +const num = z + .union([z.number(), z.string()]) + .optional() + .nullable() + .transform((v) => { + if (v === null || v === undefined || v === '') return undefined; + const n = typeof v === 'number' ? v : Number(v.replace(/[\s,]/g, '')); + return Number.isFinite(n) ? n : undefined; + }); + +/** Объявление в фиде. Обязательны только ID и ссылка, остальное — по возможности. */ +const FeedItem = z.object({ + id: z.union([z.string().min(1), z.number()]).transform(String), + url: z.url(), + title: text, + description: text, + price: num, + currency: text, + district: text, + propertyType: text, + rooms: num, + floor: num, + floorsTotal: num, + area: num, + address: text, + ownerName: text, + phone: text, + dealType: z.enum(['sale', 'rent']).optional().nullable(), + region: text, + isAgency: z.boolean().optional().nullable(), + publishedAt: z.iso.datetime({ offset: true }).optional().nullable(), + photos: z.array(z.string()).optional().default([]), +}); +type FeedItem = z.infer; + +const FeedPage = z.object({ + items: z.array(z.unknown()), + /** Курсор следующей страницы; нет или null — страниц больше нет. */ + next: z.string().nullish(), +}); + +const MAX_PAGES = 20; + +/** + * Источник объявлений — JSON-фид по HTTP (формат описан в docs/PARSER.md). + * Подходит для официального API/фида list.am или провайдера данных: адаптер не ходит + * на сайт list.am сам, а читает то, что отдаёт настроенный фид. + */ +export class FeedListingSource implements ListingSource { + readonly name = 'feed'; + + constructor( + private readonly url: string, + private readonly token: string | undefined, + private readonly fetchFn: typeof fetch = fetch, + ) {} + + async *fetchLatest( + filter: ListingSourceFilter, + report: (problem: string) => void, + ): AsyncIterable { + let cursor: string | undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const body = await this.request(filter, cursor); + for (const [i, raw] of body.items.entries()) { + const item = FeedItem.safeParse(raw); + if (!item.success) { + report( + `Запись ${i + 1} на странице ${page + 1} пропущена: ${item.error.issues[0]?.message}`, + ); + continue; + } + yield toSourced(item.data); + } + if (!body.next) return; + cursor = body.next; + } + } + + private async request(filter: ListingSourceFilter, cursor?: string) { + const url = new URL(this.url); + url.searchParams.set('city', filter.city); + url.searchParams.set('category', filter.category); + url.searchParams.set('ownerOnly', String(filter.ownerOnly)); + if (cursor) url.searchParams.set('cursor', cursor); + + const res = await this.fetchFn(url, { + headers: { + accept: 'application/json', + ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), + }, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`Фид ответил ${res.status} ${res.statusText}`.trim()); + const parsed = FeedPage.safeParse(await res.json()); + if (!parsed.success) throw new Error('Фид вернул данные не в ожидаемом формате'); + return parsed.data; + } +} + +function toSourced(item: FeedItem): SourcedListing { + const currency = item.currency?.toUpperCase(); + return { + externalId: item.id, + sourceUrl: item.url, + title: item.title ?? `Объявление ${item.id}`, + description: item.description, + price: item.price, + currency: (CURRENCIES as readonly string[]).includes(currency ?? '') + ? (currency as SourcedListing['currency']) + : 'USD', + district: item.district, + propertyType: item.propertyType, + rooms: item.rooms, + floor: item.floor, + floorsTotal: item.floorsTotal, + area: item.area, + address: item.address, + ownerName: item.ownerName, + phone: item.phone, + dealType: item.dealType ?? undefined, + region: item.region, + isAgency: item.isAgency ?? false, + publishedAt: item.publishedAt ? new Date(item.publishedAt) : undefined, + photoUrls: item.photos, + }; +} diff --git a/apps/api/src/parser/listam-source.test.ts b/apps/api/src/parser/listam-source.test.ts new file mode 100644 index 0000000..2d0a63c --- /dev/null +++ b/apps/api/src/parser/listam-source.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SourcedListing } from './source.ts'; +import { LISTAM_CATEGORIES, ListAmListingSource } from './listam-source.ts'; + +const filter = { city: 'yerevan', category: 'apartments', ownerOnly: true }; +const noSleep = () => Promise.resolve(); + +const CATEGORY_PAGE = ` +## Недвижимость + +Топ объявления + +[![Image 2](https://img.list.am/g/263/101571263.webp) $1,600,000 Склад в Каракерте, 20,000 кв.м. Монолит, 20,000 кв.м. Каракерт](https://www.list.am/ru/item/24154105?ld_src=2) + +Обычные объявления + +[![Image 26](https://img.list.am/g/032/100081032.webp) $299,000 4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж Кентрон](https://www.list.am/ru/item/14290392?ld_src=2) + +[![Image 28](https://img.list.am/g/914/102407914.webp) 650,000֏в месяц 2-комн. квартира в новостройке на пр. Аршакуняц в центре, 56 кв.м., 2/18 этаж 2 ком., 56 кв.м., 2/18 этаж ![Image 29](https://www.list.am/img/redesign/icons/info_gray.svg)Недвижимость не проверена Агентство Кентрон](https://www.list.am/ru/item/23836325?ld_src=2) + +[![Image 30](https://img.list.am/g/212/100036212.webp) 28,750,000֏ 2-комн. квартира в новостройке, 41-я улица в Мецаморе, 87 кв.м. 2 ком., 87 кв.м. Мецамор](https://www.list.am/ru/item/995117414?ld_src=2) + +[![Image 32](https://img.list.am/g/466/102314466.webp) €90 Участок общего назначения в Зовуни, 3,100 кв.м. 3100 кв.м. Зовуни](https://www.list.am/ru/item/24005094?ld_src=2) +`; + +/** Страница объявления, как её отдаёт ридер с x-target-selector (снято с list.am в октябре 2026). */ +const itemPage = (address: string, description: string, extra = '') => `Title: 2-комн. квартира - Продажа квартир + +URL Source: https://www.list.am/ru/item/14290392 + +Markdown Content: +[${address}](https://www.list.am/ru/item/14290392) + +${extra} + +37 кв.м. + +Общая площадь + +3 + +Этаж + +9 + +Этажей в доме + +2.8 м + +Высота потолков + +4 + +Количество комнат + +1 + +Количество санузлов + +Регион + +О здании + +Тип здания + +Монолит + +Описание + +${description} + +Номер объявления 14290392 Размещено 08.10.2026 Обновлено 08.10.2026, 19:34 + +Links/Buttons: +This page does not seem to contain any buttons/links. +`; +const ITEM_PAGE = itemPage('Nalbandyan street 5, Երևան', 'Светлая квартира в центре.\n\nТорг & ипотека'); + +function fakeFetch(pages: Record) { + return vi.fn().mockImplementation(async (input) => { + const url = String(input); + for (const [needle, body] of Object.entries(pages)) { + if (url.endsWith(needle)) return new Response(body, { status: 200 }); + } + return new Response('not found', { status: 404 }); + }); +} + +async function collect(source: ListAmListingSource, report = vi.fn()) { + const items: SourcedListing[] = []; + for await (const item of source.fetchLatest(filter, report)) items.push(item); + return items; +} + +const source = (fetchFn: typeof fetch, fetchDetails = false) => + new ListAmListingSource({ fetchFn, fetchDetails, sleep: noSleep }); + +describe('ListAmListingSource', () => { + it('parses cards: regular first, promoted last, canonical url, parsed fields', async () => { + const items = await collect(source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' }))); + + // Мецамор и Зовуни — не Ереван, отфильтрованы; топ-склад — тоже. Агентство — ownerOnly. + expect(items.map((i) => i.externalId)).toEqual(['14290392']); + expect(items[0]).toMatchObject({ + sourceUrl: 'https://www.list.am/item/14290392', + title: '4-комн. квартира на ул. Налбандяна в центре, 155 кв.м., 3/9 этаж 4 ком., 155 кв.м., 3/9 этаж', + price: 299_000, + currency: 'USD', + district: 'Кентрон', + propertyType: 'apartment', + rooms: 4, + floor: 3, + floorsTotal: 9, + area: 155, + photoUrls: ['https://img.list.am/f/032/100081032.webp'], + }); + }); + + it('yields promoted cards after regular ones', async () => { + const page = ` +Топ объявления + +[![Image 2](https://img.list.am/g/1/1.webp) $50,000 1-комн. квартира, 30 кв.м. Арабкир](https://www.list.am/ru/item/111?ld_src=2) + +Обычные объявления + +[![Image 3](https://img.list.am/g/2/2.webp) $60,000 2-комн. квартира, 40 кв.м. Арабкир](https://www.list.am/ru/item/222?ld_src=2) +`; + const items = await collect(source(fakeFetch({ '/ru/category/60': page, '/ru/category/56': '' }))); + expect(items.map((i) => i.externalId)).toEqual(['222', '111']); + }); + + it('keeps agency cards when ownerOnly is off', async () => { + const items: SourcedListing[] = []; + const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' })); + for await (const item of src.fetchLatest({ ...filter, ownerOnly: false }, vi.fn())) + items.push(item); + expect(items.map((i) => i.externalId)).toContain('23836325'); + const agency = items.find((i) => i.externalId === '23836325')!; + expect(agency).toMatchObject({ price: 650_000, currency: 'AMD', rooms: 2, floor: 2, floorsTotal: 18 }); + expect(agency.title).not.toContain('Недвижимость не проверена'); + }); + + it('imports non-Yerevan cards when city filter is off', async () => { + const items: SourcedListing[] = []; + const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '' })); + for await (const item of src.fetchLatest({ ...filter, city: 'armenia' }, vi.fn())) + items.push(item); + expect(items.map((i) => i.externalId)).toContain('24005094'); + expect(items.find((i) => i.externalId === '24005094')).toMatchObject({ + price: 90, + currency: 'EUR', + // Тип объекта задаёт категория list.am, а не текст карточки. + propertyType: 'apartment', + dealType: 'sale', + }); + }); + + it('название города, оканчивающееся как район Еревана, районом не считается', async () => { + const page = `Обычные объявления + +[![Image 1](https://img.list.am/g/1/1.webp) $90,000 Дом на ул. Самвела Варданяна, 120 кв.м. 3 ком., 120 кв.м. Бюрегаван](https://www.list.am/ru/item/995000001?ld_src=2) + +[![Image 2](https://img.list.am/g/1/2.webp) $95,000 Дом в Аване, 100 кв.м. 3 ком., 100 кв.м. Аван](https://www.list.am/ru/item/995000002?ld_src=2) +`; + const house = LISTAM_CATEGORIES.find((c) => c.propertyType === 'house' && c.dealType === 'sale')!; + const cards = await source(fakeFetch({ [`/ru/category/${house.id}`]: page })).listPage(house); + expect(cards[0]).toMatchObject({ region: 'Котайк', district: 'Бюрегаван' }); + expect(cards[1]).toMatchObject({ region: 'Ереван', district: 'Аван' }); + }); + + it('listPage: один запрос, тип сделки и метка агентства из карточки', async () => { + const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE }); + const rent = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'rent')!; + const cards = await source(fetchFn, true).listPage(rent); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(cards.every((c) => c.dealType === 'rent')).toBe(true); + expect(cards.find((c) => c.externalId === '23836325')).toMatchObject({ isAgency: true, region: 'Ереван', district: 'Кентрон' }); + expect(cards.find((c) => c.externalId === '14290392')).toMatchObject({ isAgency: false }); + // Обычные объявления идут раньше «Топ». + expect(cards.at(-1)!.externalId).toBe('24154105'); + }); + + it('enrich: вне Еревана населённый пункт и область берутся из адреса', async () => { + const page = itemPage('41-я улица 5, Мецамор', 'Дом.', 'Агентство'); + const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/995117414': page }), true); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const card = (await src.listPage(sale)).find((c) => c.externalId === '995117414')!; + // Область и населённый пункт известны уже по карточке — без запроса страницы объявления. + expect(card).toMatchObject({ region: 'Армавир', district: 'Мецамор' }); + expect(card.isAgency).toBe(false); + // Метка агентства бывает только на странице объявления — она тоже учитывается. + expect(await src.enrich(card)).toMatchObject({ region: 'Армавир', district: 'Мецамор', address: '41-я улица 5, Мецамор', isAgency: true }); + }); + + it('enriches from the item page when fetchDetails is on', async () => { + const items = await collect( + source( + fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': '', '/ru/item/14290392': ITEM_PAGE }), + true, + ), + ); + expect(items[0]).toMatchObject({ + description: 'Светлая квартира в центре.\nТорг & ипотека', + address: 'Nalbandyan street 5, Երևան', + area: 37, + floor: 3, + floorsTotal: 9, + rooms: 4, + photoUrls: ['https://img.list.am/f/032/100081032.webp'], + // «Размещено 08.10.2026» — начало этого дня по Еревану. + publishedAt: new Date('2026-10-07T20:00:00Z'), + isAgency: false, + }); + }); + + it('ридер заблокировал запросы без ключа — страницы категорий читаются с ключом', async () => { + const fetchFn = vi.fn().mockImplementation(async (_input, init) => + new Headers(init?.headers).get('authorization') + ? new Response(CATEGORY_PAGE, { status: 200 }) + : new Response('AuthenticationRequiredError: blocked from performing anonymous queries', { status: 401 }), + ); + const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + expect((await src.listPage(sale)).length).toBeGreaterThan(0); + expect(fetchFn).toHaveBeenCalledTimes(2); + // Следующий запрос сразу идёт с ключом, без лишнего отказа. + expect((await src.listPage(sale)).length).toBeGreaterThan(0); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it('publishedAt: дата из подвала страницы, отдельным запросом', async () => { + const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026Обновлено 08.10.2026, 20:01\n'; + const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer }); + const src = source(fetchFn, true); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!; + expect(await src.publishedAt(card)).toEqual(new Date('2026-10-04T20:00:00Z')); + expect(new Headers(fetchFn.mock.calls.at(-1)![1]!.headers).get('x-target-selector')).toBe('.footer'); + expect(await source(fakeFetch({ '/ru/item/14290392': 'Markdown Content:\n' }), true).publishedAt(card)).toBeUndefined(); + await expect(source(fakeFetch({ '/ru/item/14290392': 'Just a moment...' }), true).publishedAt(card)).rejects.toThrow('Cloudflare'); + }); + + it('inspect с ключом: один запрос HTML — точное время размещения и все детали', async () => { + const attr = (value: string, label: string) => + `

${value}

${label}

`; + const html = `Дом +
Код 3YАгентство
+
${attr('77.3 кв.м.', 'Общая площадь')}${attr('2', 'Этаж')}${attr('4', 'Этажей в доме')}${attr('3', 'Количество комнат')}
+
Описание
Первая строка
Вторая & третья
+`; + const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': html }); + const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!; + const headers = (n: number) => new Headers(fetchFn.mock.calls.at(n)![1]!.headers); + // Большая страница категории читается без ключа: ключ оплачивается по объёму ответа. + expect(headers(0).get('authorization')).toBeNull(); + + // Старое объявление: один маленький запрос подвала. + const old = await src.inspect(card, () => false); + expect(old.publishedAt).toEqual(new Date('2026-10-08T15:38:04Z')); + expect(old.description).toBeUndefined(); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(headers(-1).get('x-target-selector')).toBe('.footer'); + expect(headers(-1).get('authorization')).toBe('Bearer jina_key'); + expect(headers(-1).get('x-return-format')).toBe('html'); + expect(headers(-1).get('x-engine')).toBe('browser'); + + // Свежее: подвал и блоки с деталями. + const full = await src.inspect(card, () => true); + expect(fetchFn).toHaveBeenCalledTimes(4); + expect(full).toMatchObject({ + publishedAt: new Date('2026-10-08T15:38:04Z'), address: 'улица Маргаряна 17, Ереван', area: 77.3, floor: 2, + floorsTotal: 4, rooms: 3, description: 'Первая строка\nВторая & третья', isAgency: true, + }); + }); + + it('ключ отклонён (кончился баланс): объявление проверяется без ключа', async () => { + const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n'; + const fetchFn = vi.fn().mockImplementation(async (url, init) => { + if (new Headers(init!.headers).get('authorization')) return new Response('no balance', { status: 402 }); + return new Response(String(url).includes('/category/') ? CATEGORY_PAGE : footer); + }); + const src = new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!; + expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z')); + const calls = fetchFn.mock.calls.length; + // Дальше ключ час не используется: лишних отклонённых запросов нет. + await src.inspect(card, () => false); + expect(fetchFn.mock.calls.length).toBe(calls + 1); + }); + + it('проверка «объявление ещё на сайте»: есть подвал — да; нет подвала и «не существует» — снято; иначе неизвестно', async () => { + const reader = (pages: Record) => + vi.fn().mockImplementation(async (url, init) => { + const page = pages[String(url).split('/item/')[1]!] ?? {}; + const text = new Headers(init!.headers).get('x-target-selector') === '.footer' ? page.footer : page.h1; + return text === undefined ? new Response('No content available', { status: 422 }) : new Response(text); + }); + const src = new ListAmListingSource({ + sleep: noSleep, + fetchFn: reader({ + '100': { footer: 'Номер объявления 100Размещено 04.10.2026', h1: 'Квартира' }, + '200': { h1: 'Данного объявления не существует' }, + '300': { h1: 'Квартира' }, + '400': { footer: 'Just a moment...' }, + }), + }); + expect(await src.isAlive('100')).toBe(true); + expect(await src.isAlive('200')).toBe(false); + // Подвал не пришёл, но и страницы «не существует» нет: ридер ошибся — решать рано. + expect(await src.isAlive('300')).toBeUndefined(); + expect(await src.isAlive('400')).toBeUndefined(); + expect(await src.isAlive('500')).toBeUndefined(); + }); + + it('страница категории: при ответе «слишком много запросов» чтение повторяется с ключом', async () => { + const fetchFn = vi.fn().mockImplementation(async (_url, init) => + new Headers(init!.headers).get('authorization') ? new Response(CATEGORY_PAGE) : new Response('rate limit', { status: 429 }), + ); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + expect((await new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key' }).listPage(sale)).length).toBeGreaterThan(0); + await expect(new ListAmListingSource({ fetchFn, sleep: noSleep }).listPage(sale)).rejects.toThrow('429'); + }); + + it('inspect без ключа: сначала дата, детали — только для свежего объявления', async () => { + const footer = 'Markdown Content:\nНомер объявления 14290392Размещено 05.10.2026\n'; + const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': footer }); + const src = source(fetchFn, true); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const card = (await src.listPage(sale)).find((c) => c.externalId === '14290392')!; + expect((await src.inspect(card, () => false)).publishedAt).toEqual(new Date('2026-10-04T20:00:00Z')); + expect(fetchFn).toHaveBeenCalledTimes(2); // список и подвал; за деталями старого объявления не ходим + await src.inspect(card, () => true); + expect(fetchFn).toHaveBeenCalledTimes(4); + }); + + it('страница без подвала с датой и страница с проверкой Cloudflare', async () => { + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const noFooter = ITEM_PAGE.replace(/Номер объявления.*\n/, ''); + const a = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/item/14290392': noFooter }), true); + const card = (await a.listPage(sale)).find((c) => c.externalId === '14290392')!; + expect(await a.details(card)).toMatchObject({ publishedAt: undefined, description: 'Светлая квартира в центре.\nТорг & ипотека' }); + const b = source(fakeFetch({ '/ru/item/14290392': 'Just a moment...' }), true); + await expect(b.details(card)).rejects.toThrow('Cloudflare'); + }); + + it('details() бросает ошибку, если страницу объявления прочитать не удалось', async () => { + const src = source(fakeFetch({ '/ru/category/60': CATEGORY_PAGE }), true); + const sale = LISTAM_CATEGORIES.find((c) => c.propertyType === 'apartment' && c.dealType === 'sale')!; + const [card] = await src.listPage(sale); + await expect(src.details(card!)).rejects.toThrow('Ридер ответил 404'); + }); + + it('параллельные запросы идут по одному с паузой: лимит ридера не превышается', async () => { + const sleeps: number[] = []; + const src = new ListAmListingSource({ + fetchFn: fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/56': CATEGORY_PAGE }), + sleep: async (ms) => void sleeps.push(ms), + }); + await Promise.all(LISTAM_CATEGORIES.slice(0, 2).map((c) => src.listPage(c))); + // Первый запрос уходит сразу, второй ждёт свою очередь около 3 секунд. + expect(sleeps).toHaveLength(1); + expect(sleeps[0]).toBeGreaterThan(3000); + }); + + it('keeps card data when the item page fails', async () => { + const report = vi.fn(); + const items = await collect( + source( + fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' }), + true, + ), + report, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ externalId: '14290392', area: 155 }); + expect(report).toHaveBeenCalledOnce(); + }); + + it('paginates a category until an empty page', async () => { + const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' }); + await collect(source(fetchFn)); + const urls = fetchFn.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(true); + expect(urls.some((u) => u.endsWith('/ru/category/60/2'))).toBe(true); + expect(urls.some((u) => u.endsWith('/ru/category/60/3'))).toBe(false); + }); + + it('fails on an unknown category', async () => { + const src = source(fakeFetch({})); + await expect(async () => { + for await (const _ of src.fetchLatest({ ...filter, category: 'boats' }, vi.fn())) void _; + }).rejects.toThrow('Неизвестная категория'); + }); + + it('accepts dashed category names like apartments-rent', async () => { + const fetchFn = fakeFetch({ '/ru/category/56': CATEGORY_PAGE }); + const src = new ListAmListingSource({ fetchFn, sleep: noSleep, fetchDetails: false }); + const items = []; + for await (const item of src.fetchLatest({ ...filter, category: 'apartments-rent' }, vi.fn())) + items.push(item); + expect(items.length).toBeGreaterThan(0); + const urls = fetchFn.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.endsWith('/ru/category/56'))).toBe(true); + expect(urls.some((u) => u.endsWith('/ru/category/60'))).toBe(false); + }); + + it('fails the run when the reader errors', async () => { + const src = source(fakeFetch({})); + await expect(collect(src)).rejects.toThrow('Ридер ответил 404'); + }); + + it('страницы категорий читаются без ключа ридера', async () => { + const fetchFn = fakeFetch({ '/ru/category/60': CATEGORY_PAGE, '/ru/category/60/2': '', '/ru/category/56': '' }); + await collect(new ListAmListingSource({ fetchFn, sleep: noSleep, apiKey: 'jina_key', fetchDetails: false })); + for (const call of fetchFn.mock.calls) expect(new Headers(call[1]!.headers).get('authorization')).toBeNull(); + }); + +}); + +describe('характеристики объявления', () => { + it('удобства, правила проживания и остальные поля; отсутствующие удобства не попадают', async () => { + const { parseFeatures } = await import('./listam-source.ts'); + const item = (main: string, sub?: string, off = false) => + `

${main}

${ + sub ? `

${sub}

` : '' + }
`; + const html = [ + `
${item('105 кв.м.', 'Общая площадь')}${item('1', 'Количество санузлов')}
`, + `
Бытовая техника
${item('Холодильник')}${item('Плита', undefined, true)}${item('Стиральная машина')}
`, + `
Удобства
${item('Мебель', 'С мебелью')}${item('Кондиционер', undefined, true)}${item('Интернет')}
`, + `
Правила проживания
${item('Можно с детьми', 'Да')}${item('Можно с животными', 'По договоренности')}
`, + `
Условия
${item('600,000 ֏', 'В месяц')}${item('Предоплата', '1 месяц')}${item('50%', 'Комиссия с арендатора')}
`, + ].join(''); + expect(parseFeatures(html)).toEqual({ + children: 'yes', + pets: 'negotiable', + amenities: [ + { group: 'Бытовая техника', items: ['Холодильник', 'Стиральная машина'] }, + { group: 'Удобства', items: ['Интернет'] }, + ], + attrs: [ + { label: 'Количество санузлов', value: '1' }, + { label: 'Мебель', value: 'С мебелью' }, + { label: 'Можно с детьми', value: 'Да' }, + { label: 'Можно с животными', value: 'По договоренности' }, + { label: 'Предоплата', value: '1 месяц' }, + { label: 'Комиссия с арендатора', value: '50%' }, + ], + }); + expect(parseFeatures('
адрес
')).toBeUndefined(); + }); +}); + +describe('галерея объявления', () => { + it('ссылки на все фото берутся из скрипта галереи, без повторов', async () => { + const { parseGallery } = await import('./listam-source.ts'); + const html = ``; + expect(parseGallery(html)).toEqual(['https://img.list.am/f/571/1.webp', 'https://img.list.am/f/551/2.webp', 'https://s.list.am/f/9/3.jpg']); + expect(parseGallery('
нет галереи
')).toEqual([]); + }); +}); diff --git a/apps/api/src/parser/listam-source.ts b/apps/api/src/parser/listam-source.ts new file mode 100644 index 0000000..ee6e201 --- /dev/null +++ b/apps/api/src/parser/listam-source.ts @@ -0,0 +1,733 @@ +import { regionOfLocality, trailingLocality, type Allowance, type DealType, type ListingFeatures, type PropertyType } from '@lr/shared'; +import type { + ListingSource, + ListingSourceFilter, + SourcedListing, +} from './source.ts'; + +export interface ListAmSourceOptions { + /** База ридера, через который читаются страницы list.am. */ + readerBase?: string; + /** Ключ Jina Reader (бесплатный): без него лимит ~20 запросов/мин и чтение идёт медленнее. */ + apiKey?: string; + /** Догружать страницу объявления (описание, адрес, обложка фото). По умолчанию true. */ + fetchDetails?: boolean; + fetchFn?: typeof fetch; + /** Пауза между запросами; в тестах подменяется. */ + sleep?: (ms: number) => Promise; +} + +const LISTAM = 'https://www.list.am'; +const READER = 'https://r.jina.ai/'; +/** За один запуск обходим не больше стольких страниц каждой категории. */ +const MAX_PAGES = 10; +/** Анонимный лимит ридера ~20/мин; с ключом — на порядок больше. */ +const DELAY_ANON_MS = 3_100; +const DELAY_KEY_MS = 600; +/** Блоки страницы объявления: адрес, метка агентства, характеристики, заголовки разделов, описание, подвал с датой. */ +const ITEM_SELECTOR = '.loc, .po79, .attr, .gt, [itemprop=description], .footer'; +const ITEM_SELECTOR_HTML = '.loc, .po79, .gt, .attr, [itemprop=description]'; + +/** Категории list.am по типу объекта: ID взяты со страниц раздела «Недвижимость» (/ru/category/54). */ +const CATEGORY_IDS = { + apartment: { sale: 60, rent: 56 }, + house: { sale: 1386, rent: 1377 }, + commercial: { sale: 199, rent: 59 }, + land: { sale: 55, rent: 270 }, + garage: { sale: 173, rent: 175 }, +} as const satisfies Record>; + +export interface ListAmCategory { + id: number; + propertyType: PropertyType; + dealType: DealType; +} + +/** Все категории list.am, которые умеет читать парсер. */ +export const LISTAM_CATEGORIES: ListAmCategory[] = Object.entries(CATEGORY_IDS).flatMap( + ([propertyType, ids]) => + (['sale', 'rent'] as const).map((dealType) => ({ + id: ids[dealType], + propertyType: propertyType as PropertyType, + dealType, + })), +); + +/** Имена наборов категорий для fetchLatest: `apartments`, `apartments-sale`, `houses`, … */ +const PLURAL: Record = { + apartment: 'apartments', house: 'houses', commercial: 'commercial', land: 'land', garage: 'garages', +}; +const CATEGORIES: Record = {}; +for (const c of LISTAM_CATEGORIES) { + const name = PLURAL[c.propertyType]; + (CATEGORIES[name] ??= []).push(c); + CATEGORIES[`${name}-${c.dealType}`] = [c]; +} + +/** Районы Еревана, как их пишет list.am → название в справочнике CRM. */ +const DISTRICT_ALIASES: Record = { + арабкир: 'Арабкир', + ачапняк: 'Аджапняк', + аджапняк: 'Аджапняк', + аван: 'Аван', + давташен: 'Давташен', + эребуни: 'Эребуни', + 'канакер зейтун': 'Канакер-Зейтун', + кентрон: 'Кентрон', + 'малатия себастия': 'Малатия-Себастия', + 'нор норк': 'Нор-Норк', + 'норк мараш': 'Норк-Мараш', + нубарашен: 'Нубарашен', + шенгавит: 'Шенгавит', +}; +const DISTRICT_KEYS = Object.keys(DISTRICT_ALIASES).sort((a, b) => b.length - a.length); + +const CURRENCY_SYMBOLS: Record = { + $: 'USD', + '€': 'EUR', + '₽': 'RUB', + '֏': 'AMD', +}; + +/** Служебные плашки внутри текста карточки — в название не входят. */ +const BADGE_PHRASES = ['Недвижимость не проверена', 'Проверено по кадастру', 'Срочно!', 'Эксклюзив']; + +interface Card { + id: string; + text: string; + thumbUrl?: string; + agency: boolean; +} + +const normalize = (v: string) => + v + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ' '); + +/** Ключи категорий нормализованы так же, как ввод пользователя («apartments-rent» → «apartments rent»). */ +const CATEGORY_LOOKUP = new Map( + Object.entries(CATEGORIES).map(([key, ids]) => [normalize(key), ids] as const), +); + +/** + * Источник объявлений — list.am, читаемый через публичный ридер (по умолчанию Jina Reader). + * Сам адаптер на list.am не ходит и никакой защиты не обходит: читает markdown-снимок + * страницы, который отдаёт ридер (docs/PARSER.md). + * + * Ограничения источника: телефон на list.am открывается отдельным запросом по нажатию + * и не импортируется. Карточка всегда ведёт на оригинал по sourceUrl. + */ +export class ListAmListingSource implements ListingSource { + readonly name = 'list.am'; + + private readonly readerBase: string; + private apiKey?: string; + private readonly defaultKey?: string; + private readonly fetchDetails: boolean; + private readonly fetchFn: typeof fetch; + private readonly sleep: (ms: number) => Promise; + + constructor(options: ListAmSourceOptions = {}) { + this.readerBase = options.readerBase ?? READER; + this.apiKey = this.defaultKey = options.apiKey; + this.fetchDetails = options.fetchDetails ?? true; + this.fetchFn = options.fetchFn ?? fetch; + this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + } + + /** + * Ключ ридера из настроек админки: строка — использовать этот ключ, undefined — ключ из + * окружения сервера, null — работать без ключа. + */ + configure(apiKey: string | null | undefined) { + const next = apiKey === null ? undefined : apiKey || this.defaultKey; + // У нового ключа свой баланс: прошлый отказ к нему не относится. + if (next !== this.apiKey) this.keyOffUntil = 0; + this.apiKey = next; + } + + async *fetchLatest( + filter: ListingSourceFilter, + report: (problem: string) => void, + ): AsyncIterable { + const categories = CATEGORY_LOOKUP.get(normalize(filter.category)); + if (!categories) { + throw new Error( + `Неизвестная категория «${filter.category}». Допустимые: ${Object.keys(CATEGORIES).join(', ')}`, + ); + } + const yerevanOnly = ['yerevan', 'ереван'].includes(normalize(filter.city)); + + for (const category of categories) { + for (let page = 1; page <= MAX_PAGES; page++) { + let cards: ListAmCard[]; + try { + cards = await this.listPage(category, page); + } catch (error) { + // Ошибка первой страницы — сбой источника; дальнейших страниц просто нет. + if (page === 1) throw error; + report( + `Категория ${category.id}, страница ${page}: ${error instanceof Error ? error.message : String(error)}`, + ); + break; + } + if (cards.length === 0) break; + for (const card of cards) { + if (filter.ownerOnly && card.isAgency) continue; + // Городской фильтр: Ереван — только карточки с районом Еревана. + if (yerevanOnly && card.region !== 'Ереван') continue; + yield await this.enrich(card, report); + } + } + } + } + + /** + * Карточки одной страницы категории — один запрос к ридеру, без захода в объявления. + * Сначала обычные объявления (от новых к старым), потом «Топ»: старые топ-дубли в начале + * выдачи иначе остановили бы обход раньше времени. + */ + async listPage(category: ListAmCategory, page = 1): Promise { + // Страницы категорий читаем без кэша ридера, чтобы не терять свежесть. + const markdown = await this.read(`${LISTAM}/ru/category/${category.id}${page > 1 ? `/${page}` : ''}`, { fresh: true }); + const { regular, promoted } = parseCards(markdown); + return [...regular.map((card) => ({ card, promoted: false })), ...promoted.map((card) => ({ card, promoted: true }))].map(({ card, promoted }) => { + const parsed = parseCard(card); + const districtKey = parsed.district ? normalize(parsed.district) : undefined; + const inYerevan = Boolean(districtKey && DISTRICT_ALIASES[districtKey]); + if (inYerevan) parsed.district = DISTRICT_ALIASES[districtKey!]; + const sourced = toSourced(card, parsed, {}, category, inYerevan); + if (!inYerevan) { + // Вне Еревана карточка заканчивается названием города или села: область — по справочнику. + const place = trailingLocality(card.text.replace(/\s*Агентство\s*$/i, '')); + if (place) Object.assign(sourced, { region: place.region, district: place.locality }); + } + return promoted ? { ...sourced, promoted } : sourced; + }); + } + + /** + * Дата размещения объявления — из подвала страницы («Номер объявления … Размещено ДД.ММ.ГГГГ»). + * Подвал запрашивается отдельно: в общем снимке страницы ридер его чаще всего отбрасывает + * (замер 08.10.2026: 1 из 12 против 13 из 13 при отдельном запросе). + * Возвращает undefined, если даты в ответе нет; бросает ошибку, если страницу прочитать не удалось. + */ + async publishedAt(card: ListAmCard): Promise { + const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: '.footer' }); + if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare'); + return parsePostedDate(markdown); + } + + /** + * Догружает страницу объявления: описание, адрес и область, точные площадь/этаж/комнаты. + * Ридеру передаётся список нужных блоков страницы (x-target-selector). + * Бросает ошибку, если страницу прочитать не удалось. + */ + async details(card: ListAmCard): Promise { + const markdown = await this.read(`${LISTAM}/ru/item/${card.externalId}`, { selector: ITEM_SELECTOR }); + if (/Just a moment/i.test(markdown)) throw new Error('страница закрыта проверкой Cloudflare'); + return applyDetails(card, parseDetails(markdown)); + } + + /** + * Проверка незнакомого объявления для конвейера: дата размещения и, если объявление свежее, детали. + * - с ключом ридера — HTML-вырезки страницы: подвал с точным временем размещения и, для + * свежего объявления, блоки с деталями; + * - без ключа — запрос подвала с датой (с точностью до дня) и, только для свежего, запрос деталей. + * `publishedAt` в ответе пуст, если даты на странице не оказалось. Бросает ошибку, если страницу + * прочитать не удалось. + */ + async inspect(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise { + if (this.apiKey && Date.now() >= this.keyOffUntil) { + try { + return await this.inspectWithKey(card, isFresh); + } catch (error) { + // Ключ отклонён или кончился баланс: час работаем без ключа (медленнее, дата без времени). + if (!/Ридер ответил 40[123] /.test(error instanceof Error ? error.message : '')) throw error; + this.keyOffUntil = Date.now() + 3_600_000; + } + } + const publishedAt = await this.publishedAt(card); + if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt }; + // Детали не прочитались — данных из списка достаточно для лида. + const full = await this.details(card).catch(() => card); + return { ...full, publishedAt }; + } + + /** + * Есть ли объявление на сайте. У снятого объявления нет подвала с номером (ридер отвечает 422), + * а заголовок страницы — «Данного объявления не существует» (замер 08.10.2026). + * undefined — понять не удалось: спросим в следующий раз. + */ + async isAlive(externalId: string): Promise { + const url = `${LISTAM}/ru/item/${externalId}`; + try { + // Без обхода кеша ридера: с ним выборка подвала у ридера ломается (422), а суточной проверке + // снимок часовой давности не мешает. + const footer = await this.read(url, { selector: '.footer' }); + if (/Just a moment/i.test(footer)) return undefined; + if (footer.includes(externalId)) return true; + } catch (error) { + if (!/Ридер ответил 422 /.test(error instanceof Error ? error.message : '')) return undefined; + } + const title = await this.read(url, { selector: 'h1' }).catch(() => ''); + return /объявления не существует/i.test(title) ? false : undefined; + } + + /** + * Все фото объявления и его характеристики. Полный список есть только в скрипте галереи на странице, поэтому страница + * читается целиком — это дороже обычного чтения, и вызывается только для объектов, которые агент + * показывает клиенту. + */ + async gallery(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }> { + const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true }); + if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare'); + // Снятое объявление list.am перенаправляет на главную: на ней нет номера объявления (замер 15.10.2026). + if (!html.includes(externalId)) return { photos: [], removed: true }; + // Страница уже прочитана целиком — заодно берём характеристики (у старых объектов их в базе нет) + // и описание: у объявлений, найденных без ключа ридера, его может не быть. + return { photos: parseGallery(html), features: parseFeatures(html), description: parseDetailsHtml(html).description }; + } + + /** + * Объявление целиком по его номеру — для объекта, который агент добавляет ссылкой. Страница + * читается так же, как для галереи. null — объявления на сайте нет. + */ + async item(externalId: string): Promise { + const html = await this.read(`${LISTAM}/ru/item/${externalId}`, { html: true, keyed: true }); + if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare'); + return parseItemPage(html, externalId); + } + + /** До этого момента ключ ридера не используется: его отклонили. */ + private keyOffUntil = 0; + + /** До этого момента запросы без ключа не делаются: ридер заблокировал их для нашего IP. */ + private anonOffUntil = 0; + + private async inspectWithKey(card: ListAmCard, isFresh: (publishedAt: Date) => boolean): Promise { + const url = `${LISTAM}/ru/item/${card.externalId}`; + const check = (html: string) => { + if (/Just a moment/i.test(html)) throw new Error('страница закрыта проверкой Cloudflare'); + return html; + }; + // Сначала только подвал (ответ в несколько строк): у «поднятого» старого объявления + // остальное читать незачем. + const footer = check(await this.read(url, { html: true, selector: '.footer', keyed: true })); + const publishedAt = parseDetailsHtml(footer).publishedAt; + if (!publishedAt || !isFresh(publishedAt)) return { ...card, publishedAt }; + const html = await this.read(url, { html: true, selector: ITEM_SELECTOR_HTML, keyed: true }).then(check, () => ''); + return { ...applyDetails(card, parseDetailsHtml(html)), publishedAt }; + } + + /** То же, что details(), но при ошибке возвращает карточку как есть и сообщает о проблеме. */ + async enrich(card: ListAmCard, report: (problem: string) => void = () => {}): Promise { + if (!this.fetchDetails) return card; + try { + return await this.details(card); + } catch (error) { + report( + `${LISTAM}/item/${card.externalId}: карточка создана без деталей (${error instanceof Error ? error.message : String(error)})`, + ); + return card; + } + } + + /** + * Момент, раньше которого нельзя начинать следующий запрос. Очередей две: запросы без ключа + * (лимит ридера ~20 в минуту) и запросы с ключом (лимит на порядок выше). + */ + private nextSlot = { anon: 0, keyed: 0 }; + + /** + * Читает страницу через ридер. Запросы одной очереди идут по одному с паузой между ними, даже + * если их запросили параллельно, — иначе упрёмся в лимит ридера. + * + * Ключ ридера оплачивается по объёму ответа, поэтому с ключом (`keyed`) читаются только + * маленькие вырезки страниц объявлений. Большие страницы категорий читаются без ключа; + * ключ для них используется, только если ридер ответил «слишком много запросов». + */ + private async read( + url: string, + opts: { fresh?: boolean; selector?: string; html?: boolean; keyed?: boolean } = {}, + ): Promise { + // Анонимные запросы с нашего IP ридер может заблокировать — тогда на время читаем всё с ключом. + const keyed = Boolean((opts.keyed || Date.now() < this.anonOffUntil) && this.apiKey); + const lane = keyed ? 'keyed' : 'anon'; + const now = Date.now(); + const startAt = Math.max(now, this.nextSlot[lane]); + this.nextSlot[lane] = startAt + (keyed ? DELAY_KEY_MS : DELAY_ANON_MS); + if (startAt > now) await this.sleep(startAt - now); + const res = await this.fetchFn(this.readerBase + url, { + headers: { + accept: 'text/plain', + ...(keyed ? { authorization: `Bearer ${this.apiKey}` } : {}), + ...(opts.fresh ? { 'x-no-cache': 'true' } : {}), + ...(opts.selector ? { 'x-target-selector': opts.selector } : {}), + // Готовый HTML страницы после проверки Cloudflare ридер отдаёт только с ключом. + ...(opts.html ? { 'x-return-format': 'html', 'x-engine': 'browser', 'x-no-cache': 'true' } : {}), + }, + signal: AbortSignal.timeout(60_000), + }); + if (res.status === 401 && !keyed && this.apiKey) { + // «Blocked from anonymous queries due to bad IP reputation» (22.10.2026): без ключа ридер не пускает. + this.anonOffUntil = Date.now() + 3_600_000; + return this.read(url, { ...opts, keyed: true }); + } + if (res.status === 429 && !keyed && this.apiKey) return this.read(url, { ...opts, keyed: true }); + if (!res.ok) throw new Error(`Ридер ответил ${res.status} на ${url}`); + return res.text(); + } +} + +/** Карточки страницы категории: «Топ объявления» идёт блоком перед «Обычные объявления». */ +function parseCards(markdown: string): { regular: Card[]; promoted: Card[] } { + const cards: { marker: 'top' | 'regular' | undefined; card: Card }[] = []; + let marker: 'top' | 'regular' | undefined; + for (const line of markdown.split('\n')) { + if (line.includes('Топ объявления')) marker = 'top'; + else if (line.includes('Обычные объявления')) marker = 'regular'; + const card = matchCard(line); + if (card) cards.push({ marker, card }); + } + return { + regular: cards.filter((c) => c.marker === 'regular').map((c) => c.card), + promoted: cards.filter((c) => c.marker !== 'regular').map((c) => c.card), + }; +} + +const CARD_RE = + /\[!\[Image[^\]]*\]\((https:\/\/img\.list\.am\/[^)\s]+)\)\s*(.+?)\]\(https:\/\/www\.list\.am\/(?:ru\/)?item\/(\d+)[^)\s]*\)/; + +function matchCard(line: string): Card | null { + const m = CARD_RE.exec(line); + if (!m) return null; + const text = m[2]!.replace(/!\[Image[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim(); + return { id: m[3]!, text, thumbUrl: m[1], agency: /Агентство/.test(text) }; +} + +interface ParsedCard { + price?: number; + currency: SourcedListing['currency']; + title: string; + rooms?: number; + floor?: number; + floorsTotal?: number; + area?: number; + district?: string; + propertyType?: string; +} + +function parseCard(card: Card): ParsedCard { + let rest = card.text; + + let currency: SourcedListing['currency'] = 'AMD'; + let price: number | undefined; + const priceM = /^(?:([$€₽])\s*)?([\d][\d\s,.]*?)\s*(֏|₽)?\s*(?:в\s*месяц)?\s+/.exec(rest); + if (priceM && priceM[2]!.replace(/\D/g, '').length > 0) { + const symbol = priceM[1] ?? priceM[3]; + if (symbol) currency = CURRENCY_SYMBOLS[symbol]!; + const n = Number(priceM[2]!.replace(/[\s,]/g, '').replace(',', '.')); + if (Number.isFinite(n) && n > 0) price = n; + rest = rest.slice(priceM[0].length); + } + + const rooms = numberMatch(rest, /(\d{1,2})\s*[-–]?\s*комн\.|(\d{1,2})\s*ком\./); + const floorM = /(\d{1,3})\s*\/\s*(\d{1,3})\s*этаж/.exec(rest); + const area = numberMatch(rest, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м/); + + let title = rest; + for (const phrase of BADGE_PHRASES) title = title.replaceAll(phrase, ' '); + // Район — последнее слово/словосочетание карточки (после плашки «Агентство» или без неё). + let district: string | undefined; + for (const key of DISTRICT_KEYS) { + // Район — отдельное слово в конце: «Бюрегаван» не должен распознаваться как «Аван». + const tail = new RegExp(`(?:^|\\s)${key.replace(/ /g, '[\\s_-]+')}\\s*$`, 'i'); + if (tail.test(title)) { + district = key; + title = title.replace(tail, ''); + break; + } + } + title = title + .replace(/\s*агентство\s*$/i, '') + .replace(/[ ,.;-]+$/, '') + .replace(/\s+/g, ' ') + .trim(); + + return { + price, + currency, + title: title || `Объявление ${card.id}`, + rooms, + floor: floorM ? Number(floorM[1]) : undefined, + floorsTotal: floorM ? Number(floorM[2]) : undefined, + area, + district, + propertyType: guessPropertyType(rest), + }; +} + +function numberMatch(text: string, re: RegExp): number | undefined { + const m = re.exec(text); + if (!m) return undefined; + const raw = m[1] ?? m[2]; + const n = Number(raw?.replace(',', '.')); + return Number.isFinite(n) ? n : undefined; +} + +function guessPropertyType(text: string): string | undefined { + const t = text.toLowerCase(); + if (/квартир/.test(t)) return 'apartment'; + if (/(?:^|[^а-яё])дом(?:[^а-яё]|$)|коттедж/.test(t)) return 'house'; + if (/офис|помещени|коммерч|склад|торгов/.test(t)) return 'commercial'; + if (/участок|земл/.test(t)) return 'land'; + if (/гараж|паркинг|машиномест/.test(t)) return 'garage'; + return undefined; +} + +interface ItemDetails { + description?: string; + address?: string; + rooms?: number; + floor?: number; + floorsTotal?: number; + area?: number; + publishedAt?: Date; + isAgency?: boolean; + features?: ListingFeatures; +} + +/** + * Детали со страницы объявления. Характеристики идут парами «значение, подпись»; описание — + * между заголовком «Описание» и подвалом «Номер объявления … Размещено ДД.ММ.ГГГГ». + */ +function parseDetails(markdown: string): ItemDetails { + const details: ItemDetails = {}; + const text = markdown.replace(/\r/g, ''); + + const address = /^\[([^\]\n]+)\]\(https:\/\/www\.list\.am\/[^)\s]*item\/\d+[^)\s]*\)\s*$/m.exec(text); + if (address && !/скрыт/i.test(address[1]!)) details.address = address[1]!.trim().slice(0, 300); + + details.rooms = numberMatch(text, /(\d{1,2})\n+Количество комнат/); + details.floor = numberMatch(text, /(-?\d{1,3})\n+Этаж\n/); + details.floorsTotal = numberMatch(text, /(\d{1,3})\n+Этажей в доме/); + details.area = numberMatch(text, /([\d]+(?:[.,]\d+)?)\s*кв\.?\s*м\.?\n+Общая площадь/); + + const description = /\nОписание\n+([\s\S]*?)(?:\n+Номер объявления|\n+Links\/Buttons:|$)/.exec(text); + if (description) { + const body = description[1]! + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n'); + if (body) details.description = body.slice(0, 10_000); + } + + details.publishedAt = parsePostedDate(text); + + if (/^Агентство\s*$/m.test(text.split('\nОписание\n')[0]!)) details.isAgency = true; + return details; +} + +const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; +const decode = (v: string) => + v + .replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n: string) => String.fromCodePoint(parseInt(n, 16))) + .replace(/&([a-z]+);/g, (m, name: string) => ENTITIES[name] ?? m); + +/** Детали из HTML страницы объявления: характеристики идут парами «значение, подпись». */ +function parseDetailsHtml(html: string): ItemDetails { + const details: ItemDetails = {}; + + const attrs = new Map(); + for (const m of html.matchAll(/>([^<>]{1,60})<\/p>

([^<>]{1,60})<\/p>/g)) { + attrs.set(decode(m[2]!).trim(), decode(m[1]!).trim()); + } + const attr = (label: string) => { + const n = Number(attrs.get(label)?.replace(',', '.').match(/-?[\d.]+/)?.[0]); + return Number.isFinite(n) ? n : undefined; + }; + details.rooms = attr('Количество комнат'); + details.floor = attr('Этаж'); + details.floorsTotal = attr('Этажей в доме'); + // У домов «Общая площадь» — площадь дома; площадь участка идёт отдельным полем. + details.area = attr('Общая площадь') ?? attr('Площадь дома'); + + const address = + /class="post-location-title">(?:]*><\/span>)?]*>([^<]+)<\/p>/.exec(html) ?? + /class="loc">]*>([^<]+)<\/a>/.exec(html); + if (address && !/скрыт/i.test(address[1]!)) details.address = decode(address[1]!).trim().slice(0, 300); + + const description = /itemprop="description"[^>]*>([\s\S]*?)<\/div>/.exec(html); + if (description) { + const text = decode(description[1]!.replace(//gi, '\n').replace(/<[^>]+>/g, '')) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n'); + if (text) details.description = text.slice(0, 10_000); + } + + // Точное время размещения есть только в разметке: . + const posted = /itemprop="datePosted" content="([^"]+)"/.exec(html); + if (posted) { + const d = new Date(posted[1]!); + if (!Number.isNaN(d.getTime())) details.publishedAt = d; + } + + if (/Агентство<\/span>/.test(html)) details.isAgency = true; + details.features = parseFeatures(html); + return details; +} + +/** + * Объявление со страницы list.am целиком: категория (тип сделки и объекта) — из разметки страницы, + * название — из заголовка, цена — из микроразметки, остальное — как в деталях. Район Еревана на + * странице отдельно не указан: ищем его название в заголовке и адресе («в Ачапняке»). + * null — номера объявления на странице нет: его сняли, и list.am перенаправил на главную. + */ +export function parseItemPage(html: string, externalId: string): SourcedListing | null { + if (!html.includes(externalId)) return null; + const categoryId = Number(/"category_id":(\d+)/.exec(html)?.[1] ?? [...html.matchAll(/href="\/(?:ru\/)?category\/(\d+)"/g)].at(-1)?.[1]); + const category = LISTAM_CATEGORIES.find((c) => c.id === categoryId); + const title = decode(/]*itemprop="name"[^>]*>([^<]+)<\/h1>/.exec(html)?.[1] ?? '').replace(/\s+/g, ' ').trim(); + const price = Number(/itemprop="price" content="([\d.]+)"/.exec(html)?.[1]); + const currency = /itemprop="priceCurrency" content="([A-Z]{3})"/.exec(html)?.[1]; + const details = parseDetailsHtml(html); + const text = `${title} ${details.address ?? ''}`.toLowerCase(); + // «в центре» в заголовках list.am — это Кентрон. + const district = + DISTRICT_KEYS.find((key) => new RegExp(`(?:^|[\\s,])${key.replace(/ /g, '[\\s_-]+')}`, 'i').test(text)) ?? + (/(?:^|\s)в центре(?:[\s,.]|$)/.test(text) && /ереван/.test(text) ? 'кентрон' : undefined); + const base: SourcedListing = { + externalId, + sourceUrl: `${LISTAM}/item/${externalId}`, + title: (title || `Объявление ${externalId}`).slice(0, 200), + dealType: category?.dealType, + propertyType: category?.propertyType ?? guessPropertyType(title), + isAgency: false, + price: Number.isFinite(price) && price > 0 ? price : undefined, + currency: currency && Object.values(CURRENCY_SYMBOLS).includes(currency as SourcedListing['currency']) ? (currency as SourcedListing['currency']) : 'AMD', + rooms: numberMatch(title, /(\d{1,2})\s*[-–]?\s*комн\./), + region: district ? 'Ереван' : undefined, + district: district ? DISTRICT_ALIASES[district] : undefined, + photoUrls: parseGallery(html), + }; + return applyDetails(base, details); +} + +/** Ссылки на фото из скрипта галереи: po114.init('#po114', {img:["//img.list.am/f/…", …]}). */ +export function parseGallery(html: string): string[] { + const list = /po\d+\.init\([^)]*?\{\s*img:\s*(\[[^\]]*\])/.exec(html)?.[1] ?? ''; + const urls = [...list.matchAll(/["'](\/\/(?:img|s)\.list\.am\/f\/\d+\/\d+\.(?:webp|jpe?g|png))["']/g)].map((m) => `https:${m[1]}`); + return [...new Set(urls)]; +} + +/** Уже разобраны отдельными полями либо дублируют цену — в список характеристик не идут. */ +const SKIP_LABELS = new Set(['Общая площадь', 'Площадь дома', 'Этаж', 'Этажей в доме', 'Количество комнат', 'В месяц', 'В сутки', 'Цена']); +const ALLOWANCE: Record = { да: 'yes', нет: 'no', 'по договоренности': 'negotiable', 'по договорённости': 'negotiable' }; + +/** + * Характеристики объявления. Страница состоит из блоков: первый (без заголовка) — «значение, подпись»; + * дальше идут разделы с заголовком («Бытовая техника», «Правила проживания»), в них либо пары + * «подпись, значение», либо отметки-удобства. Удобство, которого в квартире нет, помечено классом disabled. + */ +export function parseFeatures(html: string): ListingFeatures | undefined { + const features: ListingFeatures = { amenities: [], attrs: [] }; + let group = ''; + let head = false; + const token = + /

([^<]{1,80})<\/div>|
[\s\S]*?

([^<]{1,80})<\/p>(?:

([^<]{1,80})<\/p>)?/g; + for (const m of html.matchAll(token)) { + if (m[1] !== undefined) { + group = decode(m[1]).trim(); + continue; + } + if (m[4] === undefined) { + head = Boolean(m[2]); + if (head) group = ''; + continue; + } + const main = decode(m[4]).trim(); + const sub = m[5] === undefined ? undefined : decode(m[5]).trim(); + if (sub === undefined) { + // Отметка-удобство: учитываем только те, что в квартире есть. + if (m[3] || !main) continue; + const bucket = features.amenities.find((a) => a.group === group) ?? features.amenities[features.amenities.push({ group, items: [] }) - 1]!; + bucket.items.push(main); + continue; + } + // В первом блоке сверху значение, снизу подпись; в разделах — наоборот, кроме строк вида «50% — Комиссия». + const valueFirst = head || (/\d/.test(main) && !/\d/.test(sub)); + const [label, value] = valueFirst ? [sub, main] : [main, sub]; + if (label === 'Можно с детьми') features.children = ALLOWANCE[value.toLowerCase()]; + else if (label === 'Можно с животными') features.pets = ALLOWANCE[value.toLowerCase()]; + if (!SKIP_LABELS.has(label)) features.attrs.push({ label, value }); + } + return features.amenities.length || features.attrs.length ? features : undefined; +} + +/** «Размещено ДД.ММ.ГГГГ» → дата. Времени на странице нет: берём начало этого дня по Еревану (UTC+4). */ +function parsePostedDate(text: string): Date | undefined { + const m = /Размещено\s*(\d{2})\.(\d{2})\.(\d{4})/.exec(text); + if (!m) return undefined; + return new Date(Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])) - 4 * 3_600_000); +} + +/** Карточка со страницы категории: то же объявление, но без деталей со страницы объявления. */ +export type ListAmCard = SourcedListing; + +function toSourced( + card: Card, + parsed: ParsedCard, + details: ItemDetails, + category: ListAmCategory, + inYerevan: boolean, +): SourcedListing { + const base: SourcedListing = { + externalId: card.id, + sourceUrl: `${LISTAM}/item/${card.id}`, + title: parsed.title.slice(0, 200), + dealType: category.dealType, + // Тип объекта задаёт категория; угадывание по тексту — запасной вариант. + propertyType: category.propertyType ?? parsed.propertyType, + isAgency: card.agency, + price: parsed.price, + currency: parsed.currency, + region: inYerevan ? 'Ереван' : undefined, + district: parsed.district, + rooms: parsed.rooms, + floor: parsed.floor, + floorsTotal: parsed.floorsTotal, + area: parsed.area, + photoUrls: card.thumbUrl ? [card.thumbUrl.replace('/g/', '/f/')] : [], + }; + return applyDetails(base, details); +} + +function applyDetails(card: SourcedListing, details: ItemDetails): SourcedListing { + const out: SourcedListing = { + ...card, + description: details.description ?? card.description, + address: details.address ?? card.address, + rooms: details.rooms ?? card.rooms, + floor: details.floor ?? card.floor, + floorsTotal: details.floorsTotal ?? card.floorsTotal, + area: details.area ?? card.area, + publishedAt: details.publishedAt ?? card.publishedAt, + isAgency: card.isAgency || Boolean(details.isAgency), + features: details.features ?? card.features, + }; + // Вне Еревана район в карточке не распознаётся: населённый пункт берём из адреса + // («улица Минска 30, Ереван» → последняя часть), область — по справочнику. + if (!out.region && details.address) { + const locality = details.address.split(',').at(-1)?.trim(); + const region = regionOfLocality(locality); + if (region) out.region = region; + if (locality && region !== 'Ереван') out.district = locality; + } + return out; +} diff --git a/apps/api/src/parser/settings.ts b/apps/api/src/parser/settings.ts new file mode 100644 index 0000000..6b3722a --- /dev/null +++ b/apps/api/src/parser/settings.ts @@ -0,0 +1,54 @@ +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { settings } from '../db/schema.ts'; +import { LISTAM_CATEGORIES } from './listam-source.ts'; + +/** Настройки парсера, которые суперадмин меняет в админке без перезапуска сервиса. */ +export const ParserSettings = z.object({ + /** Выключенный парсер не ходит на list.am сам; кнопка «Запустить сейчас» работает всегда. */ + enabled: z.boolean().default(true), + /** + * Блок «Топ» и первый проход по категории: в базу идут объявления, размещённые не раньше чем столько дней назад. + * Новые для обычного списка (опубликованные или поднятые) идут в базу всегда. + */ + freshDays: z.number().int().min(1).max(30).default(3), + /** Пауза между проходами, секунд. */ + pauseSec: z.number().int().min(0).max(3600).default(5), + /** Сколько страниц категории читать за проход, пока на них есть незнакомые объявления. */ + maxPages: z.number().int().min(1).max(10).default(3), + /** + * Разовый глубокий проход — догнать то, что вышло, пока парсер стоял: страницы читаются до maxPages + * без остановки на уже известных, новые берутся только по дате (freshDays). После прохода выключается сам. + */ + deepPass: z.boolean().default(false), + /** ID категорий list.am, которые читает парсер. */ + categories: z.array(z.number().int()).default(LISTAM_CATEGORIES.map((c) => c.id)), + /** Ключ ридера Jina; пусто — берётся из окружения сервера (JINA_API_KEY). */ + jinaApiKey: z.string().trim().max(200).default(''), + /** Использовать ключ: с ним быстрее и известно точное время размещения, но он платный. */ + useKey: z.boolean().default(true), + /** Проверять, не сняты ли с сайта объявления, по которым есть лиды. */ + removalCheck: z.boolean().default(true), + /** Сколько объявлений проверять за один заход (раз в 10 минут). */ + removalBatch: z.number().int().min(1).max(100).default(20), + /** Как часто перепроверять одно объявление, часов. */ + recheckHours: z.number().int().min(1).max(720).default(24), +}); +export type ParserSettings = z.infer; + +const KEY = 'parser'; + +export async function loadParserSettings(db: Db): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, KEY)); + return ParserSettings.parse(row?.value ?? {}); +} + +export async function saveParserSettings(db: Db, patch: Partial): Promise { + const value = ParserSettings.parse({ ...(await loadParserSettings(db)), ...patch }); + await db + .insert(settings) + .values({ key: KEY, value }) + .onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } }); + return value; +} diff --git a/apps/api/src/parser/source.ts b/apps/api/src/parser/source.ts new file mode 100644 index 0000000..71d2cd8 --- /dev/null +++ b/apps/api/src/parser/source.ts @@ -0,0 +1,60 @@ +import type { Currency, DealType, ListingFeatures } from '@lr/shared'; + +// Контракт источника объявлений. Модуль перенесён из прежней CRM (crmyerevan) и расширен +// полями из ТЗ: тип сделки, метка «агентство», имя и телефон автора, регион, дата публикации. +// +// - ListAmListingSource — снимки страниц list.am через публичный ридер (Jina Reader); +// сам сервис на list.am не ходит. Телефон автора этим путём недоступен. +// - FeedListingSource — JSON-фид по LISTING_FEED_URL (официальный API или провайдер данных). + +/** Объявление из внешнего источника. */ +export interface SourcedListing { + /** ID объявления у источника (item_id на list.am). */ + externalId: string; + sourceUrl: string; + title: string; + description?: string; + dealType?: DealType; + /** Тип объекта, как его называет источник (apartment, house, …). */ + propertyType?: string; + region?: string; + district?: string; + address?: string; + price?: number; + currency: Currency; + rooms?: number; + floor?: number; + floorsTotal?: number; + area?: number; + ownerName?: string; + phone?: string; + /** Метка list.am «Агентство». */ + isAgency: boolean; + publishedAt?: Date; + /** Ссылки на оригинальные фото. */ + photoUrls: string[]; + /** Удобства и правила проживания (дети, животные) со страницы объявления. */ + features?: ListingFeatures; + /** Карточка из блока «Топ объявления» на странице категории: его порядок ротируется. */ + promoted?: boolean; +} + +export interface ListingSourceFilter { + /** `yerevan` — только объявления с районом Еревана; иначе все. */ + city: string; + category: string; + /** true — источник сам отбрасывает объявления с меткой «Агентство». */ + ownerOnly: boolean; +} + +export interface ListingSource { + readonly name: string; + /** + * Свежие объявления, начиная с самых новых; итерацию можно прервать в любой момент. + * Некорректные записи источник пропускает и сообщает о них через `report`. + */ + fetchLatest( + filter: ListingSourceFilter, + report: (problem: string) => void, + ): AsyncIterable; +} diff --git a/apps/api/src/payroll.ts b/apps/api/src/payroll.ts new file mode 100644 index 0000000..50db257 --- /dev/null +++ b/apps/api/src/payroll.ts @@ -0,0 +1,411 @@ +import { AgentPay, agentPayout, monthsBetween, payPercent, type AccessState, type Currency, type DealKind } from '@lr/shared'; +import { and, asc, desc, eq, gte, inArray, isNull, lte, or } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { loadBillingSettings } from './billing/service.ts'; +import type { Db } from './db/client.ts'; +import { clients, companies, deals, leads, payAdjustments, payouts, users } from './db/schema.ts'; +import { badRequest, conflict, notFound } from './lib/errors.ts'; + +// Выплаты агентам: по условиям, которые задал руководитель, считается, кому сколько платить за период. + +type MemberCtx = { user: typeof users.$inferSelect; role: string; company: typeof companies.$inferSelect; access: AccessState }; +interface Guards { + member: (req: FastifyRequest) => MemberCtx; + manager: (req: FastifyRequest) => MemberCtx; + writable: (ctx: T) => T; +} + +const Period = z.object({ from: z.iso.date(), to: z.iso.date() }); +/** Текущий календарный месяц по Еревану: оклад считается за месяц целиком. */ +export function currentMonth(now = new Date()) { + const today = new Date(now.getTime() + 4 * 3_600_000).toISOString().slice(0, 10); + const [y, m] = today.split('-').map(Number) as [number, number]; + const last = new Date(Date.UTC(y, m, 0)).getUTCDate(); + return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-${String(last).padStart(2, '0')}`, today }; +} + +type Deal = typeof deals.$inferSelect; + +/** Часть комиссии сделки, которая идёт агенту: с собственника — агенту объекта, с клиента — агенту клиента. */ +const partOf = (d: Deal, userId: number) => (d.ownerAgentId === userId ? d.ownerCommission : 0) + (d.clientAgentId === userId ? d.clientCommission : 0); +const takesPart = (userId: number) => or(eq(deals.ownerAgentId, userId), eq(deals.clientAgentId, userId)); +const round2 = (n: number) => Math.round(n * 100) / 100; + +type Rates = Record; +type Payout = typeof payouts.$inferSelect; + +/** Курсы те же, что в счетах: сколько единиц валюты в одном долларе. */ +async function loadRates(db: Db): Promise { + const { rates } = await loadBillingSettings(db); + return { USD: 1, ...rates }; +} +const converterOf = (perUsd: Rates) => (amount: number, fromCur: string, toCur: string) => (amount / (perUsd[fromCur] ?? 1)) * (perUsd[toCur] ?? 1); +const converter = async (db: Db) => converterOf(await loadRates(db)); + +const Month = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/); +const monthRange = (ym: string) => { + const [y, m] = ym.split('-').map(Number) as [number, number]; + return { from: `${ym}-01`, to: `${ym}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}` }; +}; +const nextMonth = (ym: string) => { + const [y, m] = ym.split('-').map(Number) as [number, number]; + return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`; +}; +/** «ГГГГ-ММ», если период — ровно один календарный месяц: только для него ведутся начисления и выплаты. */ +const monthOf = (from: string, to: string) => { + const ym = from.slice(0, 7); + const r = monthRange(ym); + return r.from === from && r.to === to ? ym : null; +}; + +/** Начислено агенту по условиям `pay` за сделки `mine`: оклад, процент с его части комиссии, бонус за план. */ +function accrue(userId: number, pay: AgentPay | null, rates: Rates, mine: Deal[], months: number) { + const convert = converterOf(rates); + const currency: Currency = pay?.currency ?? 'USD'; + // Часть комиссии агента по его сделкам — в валюте его условий (отдельно продажа и аренда) и по валютам как есть. + const byKind: Record = { sale: 0, rent: 0 }; + const byCurrency: Record = {}; + for (const d of mine) { + const part = partOf(d, userId); + if (!part) continue; + byKind[d.kind === 'rent' ? 'rent' : 'sale'] += convert(part, d.currency, currency); + byCurrency[d.currency] = round2((byCurrency[d.currency] ?? 0) + part); + } + const commissionSale = round2(byKind.sale); + const commissionRent = round2(byKind.rent); + return { + pay, deals: mine.length, + commission: round2(commissionSale + commissionRent), commissionSale, commissionRent, byCurrency, currency, + ...agentPayout(pay, { sale: commissionSale, rent: commissionRent }, months, mine.length), + }; +} + +/** + * Кому сколько платить за период. Для календарного месяца к начисленному добавляются ручные записи + * (премии, удержания, авансы) и перерасчёты закрытых месяцев. Месяц закрывает отметка «выплачено»: + * она фиксирует условия и курсы; если сделки закрытого месяца потом меняются, разница (как и + * недоплата) переходит в ближайший следующий месяц без выплаты. + */ +export async function payroll(db: Db, companyId: number, from: string, to: string, onlyUserId?: number) { + const perUsd = await loadRates(db); + const month = monthOf(from, to); + const members = await db + .select() + .from(users) + .where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined)) + .orderBy(users.id); + const [paid, manual] = month + ? await Promise.all([ + db.select().from(payouts).where(and(eq(payouts.companyId, companyId), onlyUserId ? eq(payouts.userId, onlyUserId) : undefined)).orderBy(payouts.month), + db.select().from(payAdjustments).where(and(eq(payAdjustments.companyId, companyId), onlyUserId ? eq(payAdjustments.userId, onlyUserId) : undefined)).orderBy(payAdjustments.id), + ]) + : [[], []]; + // Закрытые месяцы пересчитываются заново — нужны и их сделки. + const since = paid.reduce((min, p) => (`${p.month}-01` < min ? `${p.month}-01` : min), from); + const rows = await db + .select() + .from(deals) + .where(and(eq(deals.companyId, companyId), gte(deals.dealDate, since), lte(deals.dealDate, to), onlyUserId ? takesPart(onlyUserId) : undefined)); + const months = monthsBetween(from, to); + const dealsOf = (userId: number, test: (day: string) => boolean) => rows.filter((d) => (d.ownerAgentId === userId || d.clientAgentId === userId) && test(d.dealDate)); + + return { + from, + to, + months, + month, + agents: members.map((u) => { + const base = { userId: u.id, name: u.name, role: u.role }; + if (!month) { + const row = accrue(u.id, u.pay, perUsd, dealsOf(u.id, (day) => day >= from && day <= to), months); + return { ...base, ...row, accrued: row.total, adjustments: [], adjustmentsTotal: 0, recalc: [], recalcTotal: 0, payout: null, debt: null }; + } + const mine = paid.filter((p) => p.userId === u.id); + const byMonth = new Map(mine.map((p) => [p.month, p] as const)); + const manualOf = (ym: string) => manual.filter((a) => a.userId === u.id && a.month === ym); + const inMonth = (ym: string) => dealsOf(u.id, (day) => day.slice(0, 7) === ym); + /** Сколько по закрытому месяцу осталось доплатить (или удержать) с учётом изменений после выплаты — в валюте той выплаты. */ + const remaining = (p: Payout) => { + const conv = converterOf(p.snapshot.rates); + const accrued = accrue(u.id, p.snapshot.pay, p.snapshot.rates, inMonth(p.month), 1).total; + const extra = manualOf(p.month).reduce((sum, a) => sum + conv(a.amount, a.currency, p.currency), 0); + const carriedIn = p.snapshot.recalc.reduce((sum, r) => sum + r.amount, 0); + const settled = mine + .filter((q) => q.month > p.month) + .reduce((sum, q) => sum + q.snapshot.recalc.filter((r) => r.month === p.month).reduce((n, r) => n + converterOf(perUsd)(r.amount, q.currency, p.currency), 0), 0); + return round2(accrued + extra + carriedIn - p.amount - settled); + }; + /** Месяц, в который переходит остаток закрытого месяца: ближайший следующий без выплаты. */ + const carryTo = (ym: string) => { + let next = nextMonth(ym); + while (byMonth.has(next)) next = nextMonth(next); + return next; + }; + + const own = byMonth.get(month); + const rates = own ? own.snapshot.rates : perUsd; + const row = accrue(u.id, own ? own.snapshot.pay : u.pay, rates, inMonth(month), 1); + const convert = converterOf(rates); + const adjustments = manualOf(month).map((a) => ({ id: a.id, amount: a.amount, currency: a.currency, note: a.note })); + const adjustmentsTotal = round2(adjustments.reduce((sum, a) => sum + convert(a.amount, a.currency, row.currency), 0)); + const recalc = own + ? own.snapshot.recalc + : mine + .filter((p) => p.month < month && carryTo(p.month) === month) + .map((p) => ({ month: p.month, amount: round2(convert(remaining(p), p.currency, row.currency)) })) + .filter((r) => Math.abs(r.amount) >= 0.01); + const recalcTotal = round2(recalc.reduce((sum, r) => sum + r.amount, 0)); + const total = round2(row.total + adjustmentsTotal + recalcTotal); + return { + ...base, ...row, + /** Оклад, процент и бонус за план — без ручных записей и перерасчётов. */ + accrued: row.total, + adjustments, adjustmentsTotal, recalc, recalcTotal, total, + payout: own + ? { id: own.id, amount: own.amount, currency: own.currency, paidAt: own.paidAt, note: own.note, last: mine[mine.length - 1]!.id === own.id } + : null, + /** Остаток по закрытому месяцу: уйдёт перерасчётом в следующий открытый. */ + debt: own ? remaining(own) : null, + }; + }), + }; +} + +/** Сделки агента за период: его часть комиссии и сколько ему с неё начислено. */ +export async function agentDeals(db: Db, companyId: number, userId: number, from: string, to: string) { + const convert = await converter(db); + const [agent] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId))); + if (!agent) throw notFound(); + const currency: Currency = agent.pay?.currency ?? 'USD'; + const rows = await db + .select({ deal: deals, leadTitle: leads.title, clientName: clients.name }) + .from(deals) + .leftJoin(leads, eq(leads.id, deals.leadId)) + .leftJoin(clients, eq(clients.id, deals.clientId)) + .where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to), takesPart(userId))) + .orderBy(asc(deals.dealDate), asc(deals.id)); + const partnerIds = [...new Set(rows.flatMap((r) => [r.deal.ownerAgentId, r.deal.clientAgentId]).filter((id) => id !== userId))]; + const partners = new Map( + (partnerIds.length ? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, partnerIds)) : []).map((u) => [u.id, u.name] as const), + ); + return { + userId, + currency, + deals: rows.map(({ deal: d, leadTitle, clientName }) => { + const kind: DealKind = d.kind === 'rent' ? 'rent' : 'sale'; + const part = partOf(d, userId); + const percent = payPercent(agent.pay, kind); + const owner = d.ownerAgentId === userId; + const client = d.clientAgentId === userId; + return { + id: d.id, date: d.dealDate, kind, + leadId: d.leadId, leadTitle, clientId: d.clientId, clientName, + /** С какой стороны агент в сделке: объект, клиент или обе. */ + side: owner && client ? ('both' as const) : owner ? ('owner' as const) : ('client' as const), + /** Второй агент сделки, если стороны вели разные люди. */ + partner: owner && client ? null : (partners.get(owner ? d.clientAgentId : d.ownerAgentId) ?? null), + dealCommission: d.commission, part, dealCurrency: d.currency, + percent, + /** Начислено агенту с этой сделки — в валюте его условий. */ + share: round2((convert(part, d.currency, currency) * percent) / 100), + }; + }), + }; +} + +/** + * Динамика по месяцам: сделки и часть комиссии каждого агента за последние `count` месяцев, включая + * текущий. Комиссия — в долларах по текущему курсу, чтобы месяцы и агенты сравнивались между собой. + */ +export async function payTrend(db: Db, companyId: number, count: number, onlyUserId?: number) { + const convert = await converter(db); + const months: string[] = []; + const [y, m] = currentMonth().from.split('-').map(Number) as [number, number]; + for (let i = count - 1; i >= 0; i--) months.push(new Date(Date.UTC(y, m - 1 - i, 1)).toISOString().slice(0, 7)); + const members = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(and(eq(users.companyId, companyId), isNull(users.removedAt), onlyUserId ? eq(users.id, onlyUserId) : undefined)) + .orderBy(users.id); + const rows = await db + .select() + .from(deals) + .where(and(eq(deals.companyId, companyId), gte(deals.dealDate, `${months[0]}-01`), onlyUserId ? takesPart(onlyUserId) : undefined)); + const point = (list: Deal[], part: (d: Deal) => number) => ({ + deals: list.length, + commission: round2(list.reduce((sum, d) => sum + convert(part(d), d.currency, 'USD'), 0)), + }); + const of = (ym: string) => rows.filter((d) => d.dealDate.slice(0, 7) === ym); + return { + months, + agents: members.map((u) => ({ + userId: u.id, + name: u.name, + points: months.map((ym) => point(of(ym).filter((d) => d.ownerAgentId === u.id || d.clientAgentId === u.id), (d) => partOf(d, u.id))), + })), + /** По компании: сделка двух агентов считается один раз. Агенту — только его собственные цифры. */ + totals: months.map((ym) => point(of(ym), (d) => (onlyUserId ? partOf(d, onlyUserId) : d.commission))), + }; +} + +/** + * Рейтинг за месяц — виден всем участникам: место, число сделок, выполнение плана в процентах. + * Сумм комиссии и выплат в нём нет; при равном числе сделок выше тот, у кого комиссия больше. + */ +export async function rating(db: Db, companyId: number, month: string) { + const convert = await converter(db); + const { from, to } = monthRange(month); + const { agents } = await payroll(db, companyId, from, to); + return agents + .map((a) => { + const parts = [ + a.pay?.planDeals != null ? a.deals / a.pay.planDeals : null, + a.pay?.planCommission != null ? a.commission / a.pay.planCommission : null, + ].filter((x): x is number => x !== null); + return { + userId: a.userId, name: a.name, deals: a.deals, + /** Выполнение плана: по показателю, который отстаёт сильнее; null — плана нет. */ + planPercent: parts.length ? Math.round(Math.min(...parts) * 100) : null, + usd: convert(a.commission, a.currency, 'USD'), + }; + }) + .sort((a, b) => b.deals - a.deals || b.usd - a.usd || a.userId - b.userId) + .map(({ usd: _usd, ...row }, i) => ({ place: i + 1, ...row })); +} + +export function registerPayrollRoutes(app: FastifyInstance, db: Db, g: Guards) { + /** Условия оплаты агента задаёт руководитель; null — условий нет. */ + app.post('/api/members/:id/pay', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params); + const { pay } = z.object({ pay: AgentPay.nullable() }).parse(req.body); + const [updated] = await db + .update(users) + .set({ pay }) + .where(and(eq(users.id, id), eq(users.companyId, ctx.company.id), isNull(users.removedAt))) + .returning({ id: users.id }); + if (!updated) throw notFound(); + return { ok: true }; + }); + + /** Кому сколько платить за период — руководителю. По умолчанию текущий месяц. */ + app.get('/api/payroll', async (req) => { + const ctx = g.manager(req); + const q = Period.partial().parse(req.query); + const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth(); + return payroll(db, ctx.company.id, period.from, period.to); + }); + + /** Сделки агента за период с его частью комиссии: руководителю — по любому агенту, агенту — только свои. */ + app.get('/api/payroll/deals', async (req) => { + const ctx = g.member(req); + const q = Period.partial().extend({ userId: z.coerce.number().int().optional() }).parse(req.query); + const period = q.from && q.to ? { from: q.from, to: q.to } : currentMonth(); + const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id; + return agentDeals(db, ctx.company.id, userId, period.from, period.to); + }); + + /** Участник компании, которому начисляют; месяц не из будущего. */ + const target = async (companyId: number, userId: number, month: string) => { + if (month > currentMonth().from.slice(0, 7)) throw badRequest('VALIDATION'); + const [user] = await db.select().from(users).where(and(eq(users.id, userId), eq(users.companyId, companyId), isNull(users.removedAt))); + if (!user) throw notFound(); + return user; + }; + const isPaid = async (userId: number, month: string) => + (await db.select({ id: payouts.id }).from(payouts).where(and(eq(payouts.userId, userId), eq(payouts.month, month)))).length > 0; + + /** Ручная запись за месяц: премия, штраф, аванс. Сумма со знаком, в валюте условий агента. */ + app.post('/api/payroll/adjustments', async (req) => { + const ctx = g.writable(g.manager(req)); + const body = z + .object({ userId: z.number().int(), month: Month, amount: z.number().min(-100_000_000).max(100_000_000).refine((n) => n !== 0), note: z.string().trim().min(1).max(200) }) + .parse(req.body); + const user = await target(ctx.company.id, body.userId, body.month); + if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID'); + const [row] = await db + .insert(payAdjustments) + .values({ companyId: ctx.company.id, userId: user.id, month: body.month, amount: body.amount, currency: user.pay?.currency ?? 'USD', note: body.note, createdBy: ctx.user.id }) + .returning({ id: payAdjustments.id }); + return { id: row!.id }; + }); + + app.delete('/api/payroll/adjustments/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params); + const [row] = await db.select().from(payAdjustments).where(and(eq(payAdjustments.id, id), eq(payAdjustments.companyId, ctx.company.id))); + if (!row) throw notFound(); + if (await isPaid(row.userId, row.month)) throw conflict('MONTH_PAID'); + await db.delete(payAdjustments).where(eq(payAdjustments.id, id)); + return { ok: true }; + }); + + /** «Выплачено» за месяц. Сумма по умолчанию — всё начисленное; недоплата перейдёт в следующий открытый месяц. */ + app.post('/api/payroll/payouts', async (req) => { + const ctx = g.writable(g.manager(req)); + const body = z + .object({ userId: z.number().int(), month: Month, amount: z.number().min(0).max(100_000_000).optional(), paidAt: z.iso.date().optional(), note: z.string().trim().max(200).optional() }) + .parse(req.body); + const user = await target(ctx.company.id, body.userId, body.month); + if (await isPaid(user.id, body.month)) throw conflict('MONTH_PAID'); + const { from, to } = monthRange(body.month); + const row = (await payroll(db, ctx.company.id, from, to, user.id)).agents[0]!; + const [created] = await db + .insert(payouts) + .values({ + companyId: ctx.company.id, userId: user.id, month: body.month, + amount: body.amount ?? Math.max(0, row.total), currency: row.currency, + paidAt: body.paidAt ?? currentMonth().today, note: body.note || null, + snapshot: { pay: user.pay, rates: await loadRates(db), total: row.total, recalc: row.recalc }, + createdBy: ctx.user.id, + }) + .returning({ id: payouts.id }); + return { id: created!.id }; + }); + + /** Отмена отметки — только последней у агента: более поздние выплаты могли учесть её остаток. */ + app.delete('/api/payroll/payouts/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = z.object({ id: z.coerce.number().int() }).parse(req.params); + const [row] = await db.select().from(payouts).where(and(eq(payouts.id, id), eq(payouts.companyId, ctx.company.id))); + if (!row) throw notFound(); + const [last] = await db.select({ id: payouts.id }).from(payouts).where(eq(payouts.userId, row.userId)).orderBy(desc(payouts.month)).limit(1); + if (last!.id !== id) throw conflict('PAYOUT_NOT_LAST'); + await db.delete(payouts).where(eq(payouts.id, id)); + return { ok: true }; + }); + + /** История выплат агента: руководителю — любого, агенту — своя. */ + app.get('/api/payroll/payouts', async (req) => { + const ctx = g.member(req); + const q = z.object({ userId: z.coerce.number().int().optional() }).parse(req.query); + const userId = ctx.role === 'manager' ? (q.userId ?? ctx.user.id) : ctx.user.id; + const rows = await db.select().from(payouts).where(and(eq(payouts.companyId, ctx.company.id), eq(payouts.userId, userId))).orderBy(desc(payouts.month)); + return rows.map((p) => ({ id: p.id, month: p.month, amount: p.amount, currency: p.currency, paidAt: p.paidAt, note: p.note, accrued: p.snapshot.total })); + }); + + /** Динамика по месяцам: руководителю — по всем, агенту — своя. */ + app.get('/api/payroll/trend', async (req) => { + const ctx = g.member(req); + const { months } = z.object({ months: z.coerce.number().int().min(2).max(24).default(6) }).parse(req.query); + return payTrend(db, ctx.company.id, months, ctx.role === 'manager' ? undefined : ctx.user.id); + }); + + /** Рейтинг агентов за месяц — всем участникам, без сумм. */ + app.get('/api/rating', async (req) => { + const ctx = g.member(req); + const q = z.object({ month: Month.optional() }).parse(req.query); + const month = q.month ?? currentMonth().from.slice(0, 7); + return { month, agents: await rating(db, ctx.company.id, month) }; + }); + + /** Свои условия и начисления за месяц (по умолчанию текущий) — агенту. */ + app.get('/api/my-pay', async (req) => { + const ctx = g.member(req); + const q = z.object({ month: Month.optional() }).parse(req.query); + const { from, to } = q.month ? monthRange(q.month) : currentMonth(); + const result = await payroll(db, ctx.company.id, from, to, ctx.user.id); + return { from, to, ...(result.agents[0] ?? null) }; + }); +} diff --git a/apps/api/src/pipelines/routes.ts b/apps/api/src/pipelines/routes.ts new file mode 100644 index 0000000..b970020 --- /dev/null +++ b/apps/api/src/pipelines/routes.ts @@ -0,0 +1,224 @@ +import { PIPELINES, SYSTEM_CODES, type Pipeline } from '@lr/shared'; +import { and, asc, eq, isNull, sql } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db, Tx } from '../db/client.ts'; +import { clients, events, leads, pipelineStatuses, rejectReasons } from '../db/schema.ts'; +import type { Guards } from '../leads/routes.ts'; +import { badRequest, conflict, notFound } from '../lib/errors.ts'; + +// Настройка воронок руководителем: промежуточные статусы и причины отказа можно переименовывать, +// добавлять, удалять и переставлять. Системные статусы удалить и передвинуть нельзя — на них +// завязаны пул, аналитика и биллинг. + +const PipelineParam = z.object({ pipeline: z.enum(PIPELINES) }); +const Id = z.object({ id: z.coerce.number().int().positive() }); +const Name = z.string().trim().min(1).max(60); +const Order = z.object({ ids: z.array(z.number().int().positive()).max(100) }); + +/** Системные статусы стоят на своих местах: «Новый», «Взят в работу» — в начале, «Сделка», «Отказ» — в конце. */ +const HEAD: Record = { object: ['new', 'taken'], client: ['new'] }; +const TAIL = ['deal', 'rejected']; + +async function activeStatuses(tx: Tx, companyId: number, pipeline: Pipeline) { + return tx + .select() + .from(pipelineStatuses) + .where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline), isNull(pipelineStatuses.deletedAt))) + .orderBy(asc(pipelineStatuses.position)); +} + +/** Расставляет позиции: системные в начале, промежуточные в заданном порядке, «Сделка» и «Отказ» в конце. */ +async function reposition(tx: Tx, companyId: number, pipeline: Pipeline, middleIds?: number[]) { + const all = await activeStatuses(tx, companyId, pipeline); + const byCode = (code: string) => all.find((s) => s.code === code)!; + const middle = all.filter((s) => !s.code); + const ordered = middleIds ? middleIds.map((id) => middle.find((s) => s.id === id)) : middle; + if (ordered.length !== middle.length || ordered.some((s) => !s) || new Set(middleIds ?? []).size !== (middleIds?.length ?? 0)) { + throw badRequest('VALIDATION'); + } + const next = [...HEAD[pipeline].map(byCode), ...(ordered as typeof middle), ...TAIL.map(byCode)]; + for (const [position, s] of next.entries()) { + if (s.position !== position) await tx.update(pipelineStatuses).set({ position }).where(eq(pipelineStatuses.id, s.id)); + } +} + +export function registerPipelineRoutes(app: FastifyInstance, db: Db, g: Guards) { + app.get('/api/pipelines/:pipeline', async (req) => { + const ctx = g.member(req); + const { pipeline } = PipelineParam.parse(req.params); + const table = pipeline === 'object' ? leads : clients; + const [statuses, reasons, counts] = await Promise.all([ + activeStatuses(db, ctx.company.id, pipeline), + db + .select() + .from(rejectReasons) + .where(and(eq(rejectReasons.companyId, ctx.company.id), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt))) + .orderBy(asc(rejectReasons.position)), + // Сколько карточек в каждом статусе — руководителю, чтобы решить, куда перевести их при удалении статуса. + ctx.role === 'manager' + ? db + .select({ statusId: table.statusId, n: sql`count(*)::int` }) + .from(table) + .where(eq(table.companyId, ctx.company.id)) + .groupBy(table.statusId) + : [], + ]); + const used = new Map(counts.map((c) => [c.statusId, c.n])); + return { + statuses: statuses.map((s) => ({ id: s.id, code: s.code, name: s.name, poolReturn: s.poolReturn, count: used.get(s.id) ?? 0 })), + reasons: reasons.map((r) => ({ id: r.id, code: r.code, name: r.name })), + }; + }); + + const ownStatus = async (tx: Tx, companyId: number, id: number) => { + const [s] = await tx + .select() + .from(pipelineStatuses) + .where(and(eq(pipelineStatuses.id, id), eq(pipelineStatuses.companyId, companyId), isNull(pipelineStatuses.deletedAt))); + if (!s) throw notFound(); + return s; + }; + + /** Новый статус встаёт последним среди промежуточных — перед «Сделкой». */ + app.post('/api/pipelines/:pipeline/statuses', async (req) => { + const ctx = g.writable(g.manager(req)); + const { pipeline } = PipelineParam.parse(req.params); + const { name } = z.object({ name: Name }).parse(req.body); + return db.transaction(async (tx) => { + const [row] = await tx + .insert(pipelineStatuses) + // Позиция 9000 — временная: reposition поставит статус после остальных промежуточных. + .values({ companyId: ctx.company.id, pipeline, name, position: 9000 }) + .returning(); + const middle = (await activeStatuses(tx, ctx.company.id, pipeline)).filter((s) => !s.code).map((s) => s.id); + await reposition(tx, ctx.company.id, pipeline, [...middle.filter((id) => id !== row!.id), row!.id]); + return { id: row!.id }; + }); + }); + + app.patch('/api/pipeline-statuses/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + const body = z.object({ name: Name.optional(), poolReturn: z.boolean().optional() }).parse(req.body); + const s = await ownStatus(db, ctx.company.id, id); + // Возврат в пул бывает только у объектов и только у статусов «в работе». + if (body.poolReturn !== undefined && (s.pipeline !== 'object' || ['new', 'deal', 'rejected'].includes(s.code ?? ''))) { + throw badRequest('VALIDATION'); + } + if (body.name === undefined && body.poolReturn === undefined) throw badRequest('VALIDATION'); + await db.update(pipelineStatuses).set(body).where(eq(pipelineStatuses.id, id)); + return { ok: true }; + }); + + /** + * Удаление промежуточного статуса. Если в нём есть карточки, нужно указать `moveTo` — + * другой промежуточный статус (или «Взят в работу»), куда они перейдут. + */ + app.delete('/api/pipeline-statuses/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + const { moveTo } = z.object({ moveTo: z.coerce.number().int().positive().optional() }).parse(req.query); + await db.transaction(async (tx) => { + const s = await ownStatus(tx, ctx.company.id, id); + const pipeline = s.pipeline as Pipeline; + if (s.code && (SYSTEM_CODES[pipeline] as readonly string[]).includes(s.code)) throw badRequest('SYSTEM_STATUS'); + const table = pipeline === 'object' ? leads : clients; + const used = await tx.select({ id: table.id }).from(table).where(eq(table.statusId, id)); + if (used.length) { + if (!moveTo) throw conflict('STATUS_IN_USE'); + const target = await ownStatus(tx, ctx.company.id, moveTo); + const allowed = target.pipeline === pipeline && target.id !== id && (!target.code || target.code === 'taken'); + if (!allowed) throw badRequest('BAD_STATUS'); + await tx.update(table).set({ statusId: target.id }).where(eq(table.statusId, id)); + const ids = used.map((u) => u.id); + await tx.insert(events).values( + ids.map((entityId) => ({ + companyId: ctx.company.id, userId: ctx.user.id, type: 'status', oldValue: s.name, newValue: target.name, statusId: target.id, + ...(pipeline === 'object' ? { leadId: entityId } : { clientId: entityId }), + })), + ); + } + await tx.update(pipelineStatuses).set({ deletedAt: new Date() }).where(eq(pipelineStatuses.id, id)); + await reposition(tx, ctx.company.id, pipeline); + }); + return { ok: true }; + }); + + /** Новый порядок промежуточных статусов: `ids` — все они, слева направо. */ + app.post('/api/pipelines/:pipeline/order', async (req) => { + const ctx = g.writable(g.manager(req)); + const { pipeline } = PipelineParam.parse(req.params); + const { ids } = Order.parse(req.body); + await db.transaction((tx) => reposition(tx, ctx.company.id, pipeline, ids)); + return { ok: true }; + }); + + // --- причины отказа --- + + const activeReasons = (tx: Tx, companyId: number, pipeline: Pipeline) => + tx + .select() + .from(rejectReasons) + .where(and(eq(rejectReasons.companyId, companyId), eq(rejectReasons.pipeline, pipeline), isNull(rejectReasons.deletedAt))) + .orderBy(asc(rejectReasons.position)); + + const ownReason = async (tx: Tx, companyId: number, id: number) => { + const [r] = await tx + .select() + .from(rejectReasons) + .where(and(eq(rejectReasons.id, id), eq(rejectReasons.companyId, companyId), isNull(rejectReasons.deletedAt))); + if (!r) throw notFound(); + return r; + }; + + app.post('/api/pipelines/:pipeline/reasons', async (req) => { + const ctx = g.writable(g.manager(req)); + const { pipeline } = PipelineParam.parse(req.params); + const { name } = z.object({ name: Name }).parse(req.body); + const last = (await activeReasons(db, ctx.company.id, pipeline)).at(-1); + const [row] = await db + .insert(rejectReasons) + .values({ companyId: ctx.company.id, pipeline, name, position: (last?.position ?? -1) + 1 }) + .returning(); + return { id: row!.id }; + }); + + app.patch('/api/reject-reasons/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + const { name } = z.object({ name: Name }).parse(req.body); + await ownReason(db, ctx.company.id, id); + await db.update(rejectReasons).set({ name }).where(eq(rejectReasons.id, id)); + return { ok: true }; + }); + + /** Удалённая причина пропадает из списка выбора, но остаётся в уже закрытых карточках и в аналитике. */ + app.delete('/api/reject-reasons/:id', async (req) => { + const ctx = g.writable(g.manager(req)); + const { id } = Id.parse(req.params); + await db.transaction(async (tx) => { + const r = await ownReason(tx, ctx.company.id, id); + // «Это агент» ставит кнопка в карточке объекта. + if (r.code) throw badRequest('SYSTEM_REASON'); + const rest = (await activeReasons(tx, ctx.company.id, r.pipeline as Pipeline)).filter((x) => x.id !== id); + if (!rest.some((x) => !x.code)) throw badRequest('LAST_REASON'); + await tx.update(rejectReasons).set({ deletedAt: new Date() }).where(eq(rejectReasons.id, id)); + }); + return { ok: true }; + }); + + app.post('/api/pipelines/:pipeline/reasons-order', async (req) => { + const ctx = g.writable(g.manager(req)); + const { pipeline } = PipelineParam.parse(req.params); + const { ids } = Order.parse(req.body); + await db.transaction(async (tx) => { + const all = await activeReasons(tx, ctx.company.id, pipeline); + if (ids.length !== all.length || new Set(ids).size !== ids.length || ids.some((id) => !all.some((r) => r.id === id))) { + throw badRequest('VALIDATION'); + } + for (const [position, id] of ids.entries()) await tx.update(rejectReasons).set({ position }).where(eq(rejectReasons.id, id)); + }); + return { ok: true }; + }); +} diff --git a/apps/api/src/pipelines/service.ts b/apps/api/src/pipelines/service.ts new file mode 100644 index 0000000..af06e75 --- /dev/null +++ b/apps/api/src/pipelines/service.ts @@ -0,0 +1,29 @@ +import type { Pipeline } from '@lr/shared'; +import { and, eq } from 'drizzle-orm'; +import type { Tx } from '../db/client.ts'; +import { pipelineStatuses } from '../db/schema.ts'; + +export type Status = typeof pipelineStatuses.$inferSelect; + +/** Системный статус воронки по коду (new, taken, deal, rejected). */ +export async function statusByCode(tx: Tx, companyId: number, code: string, pipeline: Pipeline = 'object'): Promise { + const [row] = await tx + .select() + .from(pipelineStatuses) + .where( + and( + eq(pipelineStatuses.companyId, companyId), + eq(pipelineStatuses.pipeline, pipeline), + eq(pipelineStatuses.code, code), + ), + ); + if (!row) throw new Error(`У компании ${companyId} нет системного статуса ${pipeline}/${code}`); + return row; +} + +/** Все статусы воронки компании, включая удалённые (они нужны для истории). */ +export const pipelineOf = (tx: Tx, companyId: number, pipeline: Pipeline) => + tx + .select() + .from(pipelineStatuses) + .where(and(eq(pipelineStatuses.companyId, companyId), eq(pipelineStatuses.pipeline, pipeline))); diff --git a/apps/api/src/reports.ts b/apps/api/src/reports.ts new file mode 100644 index 0000000..dd54313 --- /dev/null +++ b/apps/api/src/reports.ts @@ -0,0 +1,113 @@ +import { and, eq, gte, isNull, lte } from 'drizzle-orm'; +import type { Db } from './db/client.ts'; +import { companies, deals, users } from './db/schema.ts'; +import type { Config } from './lib/config.ts'; +import { payroll } from './payroll.ts'; +import { enqueueRaw } from './telegram/outbox.ts'; +import { formatPrice } from './telegram/texts.ts'; + +// Отчёты руководителю в Telegram: сводка за неделю по понедельникам и итог месяца с ведомостью +// первого числа. Включаются в настройках компании. Каждый отчёт уходит один раз — по отметке +// в настройках. + +const L = { + ru: { + week: 'Итоги недели', month: 'Итоги месяца', deals: 'Сделок', commission: 'комиссия', none: 'Сделок не было.', + toPay: 'К выплате', total: 'Всего', planDone: 'план выполнен', planNot: 'план не выполнен', sheet: 'Ведомость', locale: 'ru-RU', + }, + hy: { + week: 'Շաբաթվա արդյունքները', month: 'Ամսվա արդյունքները', deals: 'Գործարքներ', commission: 'միջնորդավճար', none: 'Գործարքներ չեն եղել։', + toPay: 'Վճարման ենթակա', total: 'Ընդամենը', planDone: 'պլանը կատարված է', planNot: 'պլանը կատարված չէ', sheet: 'Տեղեկագիր', locale: 'hy-AM', + }, + en: { + week: 'Week summary', month: 'Month summary', deals: 'Deals', commission: 'commission', none: 'No deals.', + toPay: 'To pay', total: 'Total', planDone: 'plan met', planNot: 'plan not met', sheet: 'Payroll', locale: 'en-GB', + }, +}; +type Lang = keyof typeof L; +const dict = (lang: string | null) => L[(lang as Lang) in L ? (lang as Lang) : 'ru']; + +const iso = (d: Date) => d.toISOString().slice(0, 10); +const addDays = (day: string, n: number) => iso(new Date(Date.parse(`${day}T00:00:00Z`) + n * 86_400_000)); +const sums = (byCurrency: Record) => + Object.entries(byCurrency).filter(([, n]) => n).map(([cur, n]) => formatPrice(n, cur)).join(' + ') || '—'; + +/** Сводка за период: сколько сделок и комиссии у компании и у каждого агента (его часть). */ +async function summary(db: Db, companyId: number, from: string, to: string, t: (typeof L)[Lang]) { + const rows = await db.select().from(deals).where(and(eq(deals.companyId, companyId), gte(deals.dealDate, from), lte(deals.dealDate, to))); + if (!rows.length) return { lines: [t.none], agents: [] as Awaited>['agents'] }; + const total: Record = {}; + for (const d of rows) total[d.currency] = (total[d.currency] ?? 0) + d.commission; + const { agents } = await payroll(db, companyId, from, to); + const active = agents.filter((a) => a.deals).sort((a, b) => b.deals - a.deals); + return { + lines: [`${t.deals}: ${rows.length} · ${t.commission}: ${sums(total)}`, '', ...active.map((a, i) => `${i + 1}. ${a.name} — ${a.deals} · ${sums(a.byCurrency)}`)], + agents, + }; +} + +export async function weeklyReport(db: Db, companyId: number, monday: string, lang: string | null) { + const t = dict(lang); + const from = addDays(monday, -7); + const to = addDays(monday, -1); + const day = (s: string) => new Date(`${s}T00:00:00Z`).toLocaleDateString(t.locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }); + return [`${t.week}: ${day(from)} — ${day(to)}`, ...(await summary(db, companyId, from, to, t)).lines].join('\n'); +} + +export async function monthlyReport(db: Db, config: Config, companyId: number, month: string, lang: string | null) { + const t = dict(lang); + const [y, m] = month.split('-').map(Number) as [number, number]; + const from = `${month}-01`; + const to = `${month}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}`; + const title = new Date(`${from}T00:00:00Z`).toLocaleDateString(t.locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }); + const { lines } = await summary(db, companyId, from, to, t); + // Ведомость — по месяцу целиком: с ручными записями, бонусами и перерасчётами. + const { agents } = await payroll(db, companyId, from, to); + const due = agents.filter((a) => a.total); + const total: Record = {}; + for (const a of due) total[a.currency] = (total[a.currency] ?? 0) + a.total; + return [ + `${t.month}: ${title}`, + ...lines, + '', + `${t.toPay}:`, + ...due.map((a) => `• ${a.name} — ${formatPrice(a.total, a.currency)}${a.planDone === null ? '' : ` (${a.planDone ? t.planDone : t.planNot})`}`), + `${t.total}: ${sums(total)}`, + '', + `${t.sheet}: ${config.appUrl}/team`, + ].join('\n'); +} + +/** Рассылает положенные отчёты. Время — по Еревану: неделя закрывается в понедельник, месяц — первого числа, с 9 утра. */ +export async function runReportJobs(db: Db, config: Config, now = new Date()) { + const local = new Date(now.getTime() + 4 * 3_600_000); + if (local.getUTCHours() < 9) return 0; + const today = iso(local); + const isMonday = local.getUTCDay() === 1; + const isFirst = local.getUTCDate() === 1; + if (!isMonday && !isFirst) return 0; + const lastMonth = iso(new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth() - 1, 1))).slice(0, 7); + let sent = 0; + for (const company of await db.select().from(companies)) { + const r = company.settings.reports; + const weekly = Boolean(r?.weekly && isMonday && r.sentWeekly !== today); + const monthly = Boolean(r?.monthly && isFirst && r.sentMonthly !== lastMonth); + if (!weekly && !monthly) continue; + const managers = await db + .select({ id: users.id, lang: users.lang }) + .from(users) + .where(and(eq(users.companyId, company.id), eq(users.role, 'manager'), isNull(users.removedAt))); + await db.transaction(async (tx) => { + for (const m of managers) { + if (weekly) await enqueueRaw(tx, [m.id], await weeklyReport(db, company.id, today, m.lang)); + if (monthly) await enqueueRaw(tx, [m.id], await monthlyReport(db, config, company.id, lastMonth, m.lang)); + } + await tx + .update(companies) + .set({ settings: { ...company.settings, reports: { weekly: Boolean(r?.weekly), monthly: Boolean(r?.monthly), sentWeekly: weekly ? today : r?.sentWeekly, sentMonthly: monthly ? lastMonth : r?.sentMonthly } } }) + .where(eq(companies.id, company.id)); + }); + sent += Number(weekly) + Number(monthly); + } + return sent; +} diff --git a/apps/api/src/showcase.int.test.ts b/apps/api/src/showcase.int.test.ts new file mode 100644 index 0000000..3b4f611 --- /dev/null +++ b/apps/api/src/showcase.int.test.ts @@ -0,0 +1,419 @@ +import { and, eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import sharp from 'sharp'; +import { clientPhotos, clients, companies, events, leads, listings, users } from './db/schema.ts'; +import { handleIntakeUpdate, parseBudget } from './intake/service.ts'; +import { runShowcaseJobs, slugify, validSlug } from './showcase/service.ts'; +import { renderShowcaseText, showcaseText } from './showcase/text.ts'; +import { handleUpdate } from './telegram/bot.ts'; +import { processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// Анкета соискателя в клиентском боте → клиент в CRM → страница подборки → запись на показ. + +let t: Awaited>; +let config: Awaited>['config'] & { clientBotToken: string; clientBotUsername: string }; +beforeAll(async () => { + t = await testApp('showcase'); + // Приложение и обработчики ботов смотрят в один и тот же объект настроек. + config = Object.assign(t.config, { clientBotToken: 'client:token', clientBotUsername: 'client_bot' }); +}); +afterAll(() => t.close()); + +/** Основной бот (агенты) и клиентский бот (соискатели) — две разные заглушки. */ +const main = fakeTelegram(); +const seekerBot = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: main.tg, config, sleep: async () => {} })) > 0); }; +let tgId = 9000; +let updateId = 0; + +const company = async (name = 'Арарат Недвижимость') => { + const [sa] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: `sa${Math.random()}@test.am`, passwordHash: await hashPassword('x') }) + .returning(); + const admin = t.client(await createSession(t.db, sa!.id)); + const created = await admin.post('/api/admin/companies', { + name, plan: 'team', billingModel: 'fixed', subscriptionEndsAt: new Date(Date.now() + 30 * 86_400_000).toISOString(), + }); + const api = await t.register('Арам'); + await api.post(`/api/invites/${(created.body.managerUrl as string).split('/invite/')[1]}/accept`); + const me = (await api.get('/api/me')).body; + const chat = ++tgId; + await t.db.update(users).set({ telegramId: chat }).where(eq(users.id, me.user.id)); + return { api, id: me.user.id as number, chat, slug: me.company.slug as string }; +}; + +const fromSeeker = (chat: number, part: object) => ({ update_id: ++updateId, ...part }) as never; +const say = (chat: number, text: string) => + handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) }, + fromSeeker(chat, { message: { chat: { id: chat }, from: { id: chat, first_name: 'Анна', username: 'anna', language_code: 'ru' }, text } })); +const press = (chat: number, data: string) => + handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config }, + fromSeeker(chat, { callback_query: { id: `cb${updateId}`, from: { id: chat, first_name: 'Анна', language_code: 'ru' }, data, message: { chat: { id: chat }, message_id: 1 } } })); +const last = (chat: number) => seekerBot.inbox(chat).at(-1)!; + +describe('адрес компании', () => { + it('строится из названия; служебные адреса и мусор не проходят', () => { + expect(slugify('Арарат Недвижимость')).toBe('ararat-nedvizhimost'); + expect(slugify('Հայ Տուն')).toBe('hay-tun'); + expect(validSlug('ararat')).toBe(true); + expect([validSlug('leads'), validSlug('api'), validSlug('a'), validSlug('Арарат'), validSlug('-x-')]).toEqual([false, false, false, false, false]); + }); + + it('у новой компании адрес из названия; руководитель меняет его, занятый и служебный — нельзя', async () => { + const a = await company('Арарат Недвижимость'); + const b = await company('Арарат Недвижимость'); + expect(a.slug).toBe('ararat-nedvizhimost'); + expect(b.slug).toMatch(/^ararat-nedvizhimost-\d+$/); + expect((await b.api.patch('/api/company', { slug: 'ararat-nedvizhimost' })).body.code).toBe('SLUG_TAKEN'); + expect((await b.api.patch('/api/company', { slug: 'leads' })).body.code).toBe('BAD_SLUG'); + expect((await b.api.patch('/api/company', { slug: 'Yerevan-Home' })).status).toBe(200); + expect((await b.api.get('/api/me')).body.company.slug).toBe('yerevan-home'); + }); +}); + +describe('анкета в клиентском боте', () => { + it('сумма бюджета понимается с валютой и без', () => { + expect(parseBudget('600 000 драм')).toEqual({ amount: 600000, currency: 'AMD' }); + expect(parseBudget('1500$')).toEqual({ amount: 1500, currency: 'USD' }); + expect(parseBudget('350000')).toEqual({ amount: 350000, currency: 'AMD' }); + expect(parseBudget('120000', 'sale')).toEqual({ amount: 120000, currency: 'USD' }); + expect(parseBudget('дорого')).toBeUndefined(); + }); + + it('соискатель отвечает на вопросы — клиент появляется в CRM у автора ссылки; затем подборка и запись на показ', async () => { + const agent = await company('Дом Ереван'); + // Объекты агента: подходящий, «с детьми нельзя» и из другого района. + const lead = async (title: string, over: object, features: object | null = null) => { + const id = (await agent.api.post('/api/leads', { title, dealType: 'rent', propertyType: 'apartment', district: 'Кентрон', region: 'Ереван', price: 500_000, currency: 'AMD', rooms: 2, address: 'ул. Абовяна 10', url: 'https://www.list.am/item/1', ...over })).body.id as number; + if (features) await t.db.update(leads).set({ features: features as never }).where(eq(leads.id, id)); + return id; + }; + const good = await lead('Подходящая квартира', {}, { + children: 'yes', pets: 'negotiable', amenities: [], + attrs: [{ label: 'Можно с детьми', value: 'Да' }, { label: 'Можно с животными', value: 'По договоренности' }], + }); + await lead('Без детей', {}, { children: 'no', amenities: [], attrs: [] }); + await lead('Другой район', { district: 'Аван' }); + + // Ссылку на анкету агент получает командой в основном боте. + await handleUpdate({ db: t.db, tg: main.tg, config }, { update_id: ++updateId, message: { chat: { id: agent.chat }, from: { id: agent.chat }, text: '/anketa' } }); + const link = /https:\/\/t\.me\/client_bot\?start=(\S+)/.exec(main.inbox(agent.chat).at(-1)!.text)!; + expect((await agent.api.get('/api/intake-link')).body.url).toBe(link[0]); + + const seeker = 9500; + await say(seeker, '/start'); + expect(last(seeker).text).toContain('по ссылке от вашего агента'); + await say(seeker, '/start wrong-token'); + expect(last(seeker).text).toContain('устарела'); + + // Первым делом бот спрашивает язык — сразу на трёх; пока язык не выбран, анкета не идёт. + await say(seeker, `/start ${link[1]}`); + expect(last(seeker).text).toBe('Выберите язык · Ընտրեք լեզուն · Choose your language'); + expect(last(seeker).keyboard.flat().map((b) => b.text)).toEqual(['Русский', 'Հայերեն', 'English']); + await say(seeker, 'привет'); + expect(last(seeker).text).toContain('Choose your language'); + await press(seeker, 'q:lang:hy'); + expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('հարցաթերթիկն է'); + expect(last(seeker).text).toBe('Ի՞նչ եք փնտրում'); + // Язык можно сменить по ходу командой /lang: анкета продолжается с того же вопроса. + await say(seeker, '/lang'); + await press(seeker, 'q:lang:ru'); + expect(seekerBot.inbox(seeker).at(-2)!.text).toContain('Язык: русский'); + expect(last(seeker).text).toBe('Что вы ищете?'); + // Ответ текстом там, где нужны кнопки, анкету не двигает. + await say(seeker, 'квартиру'); + expect(last(seeker).text).toContain('кнопкой'); + await press(seeker, 'q:deal:rent'); + await press(seeker, 'q:type:apartment'); + await press(seeker, 'q:place:Кентрон'); + expect(last(seeker).text).toContain('бюджет'); + await say(seeker, 'много'); + expect(last(seeker).text).toContain('Не понял сумму'); + await say(seeker, '600 000 драм'); + await press(seeker, 'q:rooms:2'); + expect(last(seeker).text).toBe('На какой срок нужна аренда?'); + await press(seeker, 'q:term:mid'); + await press(seeker, 'q:who:couple'); + await press(seeker, 'q:children:2'); + expect(last(seeker).text).toContain('Сколько лет детям'); + await say(seeker, '3 и 7 лет'); + // Животных нет — вопрос про их возраст пропускается. + await press(seeker, 'q:pets:0'); + expect(last(seeker).text).toBe('Как вас зовут?'); + await press(seeker, 'q:name:keep'); + await say(seeker, 'abc'); + expect(last(seeker).text).toContain('не номер телефона'); + await say(seeker, '091 123 456'); + expect(last(seeker).text).toContain('анкета отправлена'); + + const [client] = await t.db.select().from(clients).where(eq(clients.assigneeId, agent.id)); + expect(client).toMatchObject({ + name: 'Анна', phone: '+37491123456', kind: 'tenant', source: 'Анкета в Telegram', rentTerm: 'mid', telegram: '@anna', + request: { propertyTypes: ['apartment'], region: 'Ереван', districts: ['Кентрон'], priceTo: 600000, priceCurrency: 'AMD', rooms: [2] }, + household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } }, + }); + + // Фото после анкеты добавляется к карточке клиента. + await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) }, + fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }] } })); + expect(last(seeker).text).toContain('Фото добавлено'); + expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1); + + // Текст после анкеты не теряется: попадает в историю карточки, агенту приходит уведомление. + await say(seeker, 'Удобно звонить после 18:00'); + expect(last(seeker).text).toContain('передали агенту'); + const notes = await t.db.select().from(events).where(and(eq(events.clientId, client!.id), eq(events.type, 'comment'))); + expect(notes.at(-1)!.text).toContain('после 18:00'); + await flush(); + expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true); + + // Автору ссылки в основной бот: сводка и вопрос с кнопками. + await flush(); + const prompt = main.inbox(agent.chat).find((m) => m.text.includes('Новая анкета'))!; + expect(prompt.text).toContain('Новая анкета: Анна — арендатор'); + expect(prompt.keyboard.flat().map((b) => ('callback' in b ? b.callback : ''))).toEqual([`match:${client!.id}`, `nomatch:${client!.id}`]); + + // «Показать объекты»: подходящий объект прикреплён, клиенту в его бот ушла ссылка на подборку. + await handleUpdate({ db: t.db, tg: main.tg, config, clientTg: seekerBot.tg }, + { update_id: ++updateId, callback_query: { id: 'm1', from: { id: agent.chat }, data: `match:${client!.id}`, message: { chat: { id: agent.chat }, message_id: 5 } } }); + expect(main.inbox(agent.chat).at(-1)!.text).toContain('Подобрано объектов: 1'); + expect(last(seeker).text).toContain('?lang=ru'); + const url = /https:\/\/app\.test\/([^\s/]+)\/([^\s?]+)/.exec(last(seeker).text)!; + expect(url[1]).toBe(agent.slug); + + // Страница клиента открывается без входа и не выдаёт адрес, телефон и ссылку на объявление. + const page = await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`); + expect(page.status).toBe(200); + expect(page.body).toMatchObject({ company: 'Дом Ереван', agent: 'Арам', client: 'Анна' }); + expect(page.body.objects).toMatchObject([{ id: good, district: 'Кентрон', price: 500000, viewingDay: null }]); + // Текст — карточка по шаблону из характеристик объекта. + expect(page.body.objects[0].text).toBe('🏡 Кентрон\n\n🛏 Комнат: 2\n\n🧑‍🧑‍🧒 Дети: Да\n🐶 Животные: По договоренности\n\n💵 Цена: 500.000 драм'); + expect(Object.keys(page.body.objects[0])).not.toEqual(expect.arrayContaining(['address', 'url', 'phone', 'description', 'title'])); + expect(JSON.stringify(page.body)).not.toContain('Подходящая квартира'); + expect(JSON.stringify(page.body)).not.toContain('Абовяна'); + expect((await t.client().get(`/api/public/pages/${url[1]}/wrong-token-123`)).body.code).toBe('PAGE_NOT_FOUND'); + expect((await t.client().get(`/api/public/pages/other-company/${url[2]}`)).status).toBe(404); + + // Запись на показ: агент получает уведомление, запись видна в карточке клиента. + const day = new Date(Date.now() + 3 * 86_400_000 + 4 * 3_600_000).toISOString().slice(0, 10); + const book = (body: object) => t.client().post(`/api/public/pages/${url[1]}/${url[2]}/viewings`, body); + expect((await book({ leadId: good, day: '2020-01-01' })).body.code).toBe('BAD_DAY'); + expect((await book({ leadId: 999_999, day })).body.code).toBe('OBJECT_NOT_FOUND'); + expect((await book({ leadId: good, day, note: 'После 18:00' })).body).toEqual({ day }); + await flush(); + expect(main.inbox(agent.chat).at(-1)!.text).toBe(`📅 Анна записался на показ ${day}: Подходящая квартира\nКомментарий: После 18:00`); + // Повторная запись на тот же день уведомление не дублирует. + const before = main.inbox(agent.chat).length; + await book({ leadId: good, day, note: 'После 18:00' }); + await flush(); + expect(main.inbox(agent.chat)).toHaveLength(before); + + const card = (await agent.api.get(`/api/clients/${client!.id}`)).body; + expect(card.viewings).toMatchObject([{ leadId: good, day, title: 'Подходящая квартира', note: 'После 18:00' }]); + expect(card.pageUrl).toBe(url[0]); + // Свой домен компании: ссылка уходит на него, путь тот же; сохранение настроек руководителем домен не теряет. + const [co] = await t.db.select().from(companies).where(eq(companies.slug, url[1]!)); + await t.db.update(companies).set({ settings: { ...co!.settings, clientHost: 'arev.yandp.tech' } }).where(eq(companies.id, co!.id)); + await agent.api.patch('/api/company', { agentsSeeAll: true }); + expect((await agent.api.get(`/api/clients/${client!.id}`)).body.pageUrl).toBe(`https://arev.yandp.tech/${url[1]}/${url[2]}`); + await t.db.update(companies).set({ settings: co!.settings }).where(eq(companies.id, co!.id)); + expect(new Date(card.nextActionAt).toISOString()).toBe(new Date(`${day}T09:00:00+04:00`).toISOString()); + expect(card.events.map((e: any) => e.type)).toContain('viewing'); + expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).body.objects[0].viewingDay).toBe(day); + + // Агент закрывает страницу — ссылка больше не открывается. + expect((await agent.api.del(`/api/clients/${client!.id}/page`)).status).toBe(200); + expect((await t.client().get(`/api/public/pages/${url[1]}/${url[2]}`)).status).toBe(404); + }); + + it('страница из кабинета: кнопка с подбором прикрепляет подходящие объекты; чужой агент ссылку создать не может', async () => { + const agent = await company('Кабинет'); + const stranger = await company('Чужие'); + const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'buyer', request: { districts: ['Арабкир'] } })).body.id as number; + await agent.api.post('/api/leads', { title: 'Дом в Арабкире', dealType: 'sale', propertyType: 'house', district: 'Арабкир' }); + expect((await stranger.api.post(`/api/clients/${id}/page`, {})).status).toBe(404); + const made = (await agent.api.post(`/api/clients/${id}/page`, { match: true })).body; + expect(made.added).toBe(1); + // Повторный запрос отдаёт ту же ссылку. + expect((await agent.api.post(`/api/clients/${id}/page`, {})).body.url).toBe(made.url); + const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(made.url)!; + expect((await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects).toHaveLength(1); + }); + + it('фото на странице клиента: полный набор из объявления, обрезаны сверху и отдаются самим сервисом', async () => { + const agent = await company('Фото'); + const id = (await agent.api.post('/api/clients', { name: 'Гор', kind: 'tenant' })).body.id as number; + const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Кентрон' })).body.id as number; + // Объект из объявления: в базе парсера у него только обложка. + const [listing] = await t.db.insert(listings).values({ externalId: 'x777', url: 'https://www.list.am/item/777', title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning(); + await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/1/1.webp'], description: 'Рядом парк.' }).where(eq(leads.id, leadId)); + await agent.api.post(`/api/clients/${id}/leads`, { leadId, state: 'offered' }); + const { url } = (await agent.api.post(`/api/clients/${id}/page`, {})).body; + const [, slug, token] = /app\.test\/(\S+)\/(\S+)/.exec(url)!; + const objects = async () => (await t.client().get(`/api/public/pages/${slug}/${token}`)).body.objects; + expect((await objects())[0].photos).toEqual([`/api/public/pages/${slug}/${token}/photos/${leadId}/0`]); + + // Фоновая задача догружает все фото объявления и сохраняет текст; второй запуск ничего не делает. + const gallery = ['https://img.list.am/f/1/1.webp', 'https://img.list.am/f/2/2.webp', 'https://img.list.am/f/3/3.webp']; + let calls = 0; + const source = { gallery: async (ext: string) => { calls += 1; expect(ext).toBe('x777'); return { photos: gallery, features: { amenities: [], attrs: [{ label: 'Ремонт', value: 'Евроремонт' }] } }; } }; + // Вместе с ним готовятся объекты прошлых сценариев (у них объявления нет — только текст). + expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1); + expect(await runShowcaseJobs({ db: t.db, config, source })).toBe(0); + expect(calls).toBe(1); + const [object] = await objects(); + expect(object.photos).toHaveLength(3); + // Характеристики, которых не было в базе, взяты со страницы и попали в текст. + expect(object.text).toContain('🆕 Состояние: Евроремонт'); + expect(JSON.stringify(object)).not.toContain('list.am'); + expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.photos).toEqual(gallery); + + // Фото: JPEG, сверху срезано 8 % высоты (200 → 184). + const photo = await t.app.inject({ method: 'GET', url: object.photos[2] }); + expect(photo.statusCode).toBe(200); + expect(photo.headers['content-type']).toBe('image/jpeg'); + expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', width: 100, height: 184 }); + expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/${token}/photos/${leadId}/9` })).statusCode).toBe(404); + expect((await t.app.inject({ method: 'GET', url: `/api/public/pages/${slug}/wrong-token-12/photos/${leadId}/0` })).statusCode).toBe(404); + }); + + it('фото: снятое объявление сразу получает отметку; устаревшие фото загружаются заново при добавлении клиенту', async () => { + const agent = await company('Обновление фото'); + const id = (await agent.api.post('/api/clients', { name: 'Ани', kind: 'tenant' })).body.id as number; + const make = async (ext: string) => { + const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment' })).body.id as number; + const [listing] = await t.db.insert(listings).values({ externalId: ext, url: `https://www.list.am/item/${ext}`, title: 'Квартира', photos: ['https://img.list.am/f/1/1.webp'] }).returning(); + await t.db.update(leads).set({ listingId: listing!.id, photos: listing!.photos }).where(eq(leads.id, leadId)); + return { leadId, listingId: listing!.id }; + }; + const gone = await make('x901'); + const old = await make('x902'); + await t.db.update(leads).set({ galleryWantedAt: new Date() }).where(eq(leads.id, gone.leadId)); + const source = { gallery: async (ext: string) => (ext === 'x901' ? { photos: [], removed: true } : { photos: ['https://img.list.am/f/9/9.webp'] }) }; + await runShowcaseJobs({ db: t.db, config, source, limit: 50 }); + const [listing] = await t.db.select().from(listings).where(eq(listings.id, gone.listingId)); + expect(listing!.active).toBe(false); + expect(listing!.removedAt).not.toBeNull(); + + // Фото загружены двое суток назад — при добавлении клиенту загружаются заново. + await t.db.update(leads).set({ galleryAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(leads.id, old.leadId)); + await agent.api.post(`/api/clients/${id}/leads`, { leadId: old.leadId, state: 'offered' }); + expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.galleryAt).toBeNull(); + await runShowcaseJobs({ db: t.db, config, source, limit: 50 }); + expect((await t.db.select().from(leads).where(eq(leads.id, old.leadId)))[0]!.photos).toEqual(['https://img.list.am/f/9/9.webp']); + }); + + it('карточка объекта: телефон собственника вносит агент; фото и текст из той же логики', async () => { + const agent = await company('Карточка'); + const [listing] = await t.db.insert(listings).values({ externalId: 'x888', url: 'https://www.list.am/item/888', title: 'Квартира', photos: ['https://img.list.am/f/8/8.webp'] }).returning(); + const leadId = (await agent.api.post('/api/leads', { title: 'Квартира', dealType: 'rent', propertyType: 'apartment', district: 'Аван', price: 900, currency: 'USD', rooms: 1 })).body.id as number; + await t.db.update(leads).set({ listingId: listing!.id, photos: ['https://img.list.am/f/8/8.webp'] }).where(eq(leads.id, leadId)); + + // Телефон: приводится к общему виду, попадает и в общую базу объявлений; мусор не принимается. + expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: 'позвоните' })).body.code).toBe('BAD_PHONE'); + expect((await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(200); + let card = (await agent.api.get(`/api/leads/${leadId}`)).body; + expect(card.phone).toBe('+37491556677'); + expect(card.events.some((e: any) => e.type === 'phone' && e.newValue === '+37491556677')).toBe(true); + expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37491556677'); + // Компания поправила свой вариант — в общей базе он заменился; пустая строка убирает и вариант, и номер. + await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '077 000 111' }); + expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBe('+37477000111'); + await agent.api.post(`/api/leads/${leadId}/phone`, { phone: '' }); + expect((await agent.api.get(`/api/leads/${leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] }); + expect((await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone).toBeNull(); + // Другая компания номер этому лиду поставить не может. + const stranger = await company('Посторонние'); + expect((await stranger.api.post(`/api/leads/${leadId}/phone`, { phone: '091 55-66-77' })).status).toBe(404); + + // Карточка: текст для клиента есть сразу; открытие ставит объект в очередь на догрузку фото. + expect(card.clientText).toBe('🏡 Аван\n\n🛏 Комнат: 1\n\n💵 Цена: $900'); + expect(card).toMatchObject({ gallery: 'pending', photos: [`/api/leads/${leadId}/photos/0`] }); + const source = { gallery: async () => ({ photos: ['https://img.list.am/f/8/8.webp', 'https://img.list.am/f/9/9.webp'] }) }; + expect(await runShowcaseJobs({ db: t.db, config, source, limit: 50 })).toBeGreaterThanOrEqual(1); + card = (await agent.api.get(`/api/leads/${leadId}`)).body; + expect(card).toMatchObject({ gallery: 'ready', photos: [`/api/leads/${leadId}/photos/0`, `/api/leads/${leadId}/photos/1`] }); + + // Фото в карточке — обрезанное; без входа и из чужой компании оно недоступно. + const token = (await t.client().post('/api/auth/login', { email: (await agent.api.get('/api/me')).body.user.email, password: 'password-123' })).body.token as string; + const photo = await t.app.inject({ method: 'GET', url: card.photos[1], headers: { authorization: `Bearer ${token}` } }); + expect(await sharp(photo.rawPayload).metadata()).toMatchObject({ format: 'jpeg', height: 184 }); + expect((await t.app.inject({ method: 'GET', url: card.photos[1] })).statusCode).toBe(401); + expect((await stranger.api.get(card.photos[1])).status).toBe(404); + }); + + it('в общей базе все варианты номера: совпал у двух компаний — 80 %, у трёх — 100 %', async () => { + const [listing] = await t.db.insert(listings).values({ externalId: 'x999', url: 'https://www.list.am/item/999', title: 'Дом' }).returning(); + const agents = []; + for (const name of ['Первые', 'Вторые', 'Третьи', 'Четвёртые']) { + const a = await company(name); + const leadId = (await a.api.post('/api/leads', { title: 'Дом', dealType: 'sale', propertyType: 'house' })).body.id as number; + await t.db.update(leads).set({ listingId: listing!.id }).where(eq(leads.id, leadId)); + agents.push({ ...a, leadId }); + } + const [a, b, c, d] = agents as [typeof agents[0], typeof agents[0], typeof agents[0], typeof agents[0]]; + const set = (x: typeof a, phone: string) => x.api.post(`/api/leads/${x.leadId}/phone`, { phone }); + const variants = async (x: typeof a) => (await x.api.get(`/api/leads/${x.leadId}`)).body.phoneVariants; + const base = async () => (await t.db.select().from(listings).where(eq(listings.id, listing!.id)))[0]!.phone; + + // Первый агент ошибся в цифре: номер один, никем не подтверждён. + await set(a, '091 111 110'); + expect(await variants(a)).toEqual([{ phone: '+37491111110', votes: 1, confidence: null }]); + // Двое указали верный — в базе оба варианта, верный впереди с точностью 80 %. + await set(b, '091 111 111'); + await set(c, '+374 91 111111'); + expect(await variants(d)).toEqual([ + { phone: '+37491111111', votes: 2, confidence: 80 }, + { phone: '+37491111110', votes: 1, confidence: null }, + ]); + expect(await base()).toBe('+37491111111'); + // Третье совпадение — 100 %. Четвёртая компания видит варианты ещё до того, как внесла свой. + await set(d, '091111111'); + expect((await variants(a))[0]).toEqual({ phone: '+37491111111', votes: 3, confidence: 100 }); + // Первый исправил свой вариант: ошибочный номер из базы исчез. + await set(a, '091 111 111'); + expect(await variants(a)).toEqual([{ phone: '+37491111111', votes: 4, confidence: 100 }]); + + // Агент, который объект не ведёт, вариантов не видит — как и самого телефона. + const invite = (await a.api.post('/api/invites')).body.token as string; + const other = await t.register('Коллега'); + await other.post(`/api/invites/${invite}/accept`); + await a.api.patch('/api/company', { agentsSeeAll: true }); + expect((await other.get(`/api/leads/${a.leadId}`)).body).toMatchObject({ phone: null, phoneVariants: [] }); + }); + + it('текст по шаблону и дополнение моделью; при сбое модели остаётся шаблон', async () => { + const lead = { + title: 'Квартира', description: 'Тихий двор, рядом метро.', dealType: 'rent', district: 'Арабкир', region: 'Ереван', price: 3000, currency: 'USD', + rooms: 3, area: 84, floor: 2, floorsTotal: 9, + features: { + amenities: [{ group: 'Бытовая техника', items: ['Утюг', 'Холодильник'] }, { group: 'Удобства', items: ['Кондиционер'] }, { group: 'Виды из окон', items: ['Вид на двор'] }], + attrs: [{ label: 'Новостройка', value: 'Да' }, { label: 'Тип здания', value: 'Монолит' }, { label: 'Ремонт', value: 'Евроремонт' }, { label: 'Количество санузлов', value: '2' }, { label: 'Статус доступности', value: 'Свободно сейчас' }], + }, + }; + const card = renderShowcaseText(lead); + expect(card).toBe(['🏡 Арабкир', '', '🏢 Тип дома: Новостройка, Монолит', '🆕 Состояние: Евроремонт', '', '📐 Площадь: 84 м²', '🛏 Комнат: 3', '🚿 Санузлов: 2', '🏙 Этаж: 2/9', '', + '⚙️ Оснащение:', '✅ Кондиционер', '✅ Холодильник', '✅ Утюг', '', '💵 Цена: $3.000', '', '📅 Готова к заселению'].join('\n')); + expect(renderShowcaseText({ ...lead, features: null, price: null, district: null })).toBe('🏡 Ереван\n\n📐 Площадь: 84 м²\n🛏 Комнат: 3\n🏙 Этаж: 2/9'); + + const keyed = { ...config, openRouterKey: 'key' }; + const reply = (content: string) => (async () => new Response(JSON.stringify({ choices: [{ message: { content } }] }))) as unknown as typeof fetch; + expect(await showcaseText(keyed, lead, reply(`${card}\n\nТихий двор, метро рядом.`))).toBe(`${card}\n\nТихий двор, метро рядом.`); + // Модель вернула не карточку или упала — берём шаблон. + expect(await showcaseText(keyed, lead, reply('Вот ваш текст: ...'))).toBe(card); + expect(await showcaseText(keyed, lead, (async () => new Response('', { status: 500 })) as unknown as typeof fetch)).toBe(card); + // Без ключа модель не вызывается вовсе. + expect(await showcaseText(config, lead, (async () => { throw new Error('не должен вызываться'); }) as unknown as typeof fetch)).toBe(card); + }); + + it('без клиентского бота ссылка на анкету не выдаётся', async () => { + const agent = await company('Без бота'); + Object.assign(t.config, { clientBotToken: undefined }); + expect((await agent.api.get('/api/intake-link')).body.url).toBeNull(); + Object.assign(t.config, { clientBotToken: 'client:token' }); + }); +}); diff --git a/apps/api/src/showcase/media.ts b/apps/api/src/showcase/media.ts new file mode 100644 index 0000000..b87d11f --- /dev/null +++ b/apps/api/src/showcase/media.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import type { Config } from '../lib/config.ts'; + +// Фото для страницы клиента (логика из бота list-am-bot, image/crop.py): фото скачивается, +// сверху срезается полоса в процентах от высоты, результат отдаётся в JPEG и кладётся на диск, +// чтобы второй раз не скачивать и не обрезать. + +export type ImageFetcher = (url: string) => Promise; + +export const fetchImage: ImageFetcher = async (url) => { + const res = await fetch(url, { + headers: { referer: 'https://www.list.am/', 'user-agent': 'Mozilla/5.0 (compatible; LeadRadar/1.0)' }, + signal: AbortSignal.timeout(20_000), + }); + if (!res.ok) throw new Error(`Фото не скачалось: ${res.status}`); + const data = Buffer.from(await res.arrayBuffer()); + if (data.length > 15 * 1024 * 1024) throw new Error('Фото слишком большое'); + return data; +}; + +/** Срезать сверху `percent` % высоты и вернуть JPEG. */ +export async function cropTop(data: Buffer, percent: number): Promise { + const image = sharp(data).rotate(); + const { width, height } = await image.metadata(); + const cut = width && height ? Math.floor((height * percent) / 100) : 0; + const cropped = cut > 0 && cut < height! ? image.extract({ left: 0, top: cut, width: width!, height: height! - cut }) : image; + return cropped.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); +} + +/** Обработанное фото: с диска, если уже готово, иначе скачать, обрезать и сохранить. */ +export async function croppedPhoto(config: Config, url: string, fetcher: ImageFetcher = fetchImage): Promise { + const name = `${createHash('sha256').update(`${url}|${config.cropTopPercent}`).digest('hex').slice(0, 32)}.jpg`; + const dir = join(config.mediaDir, name.slice(0, 2)); + const file = join(dir, name); + const cached = await readFile(file).catch(() => null); + if (cached) return cached; + const result = await cropTop(await fetcher(url), config.cropTopPercent); + await mkdir(dir, { recursive: true }); + // Пишем во временный файл и переименовываем: параллельный запрос не увидит половину файла. + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, result); + await rename(tmp, file); + return result; +} diff --git a/apps/api/src/showcase/routes.ts b/apps/api/src/showcase/routes.ts new file mode 100644 index 0000000..15c7523 --- /dev/null +++ b/apps/api/src/showcase/routes.ts @@ -0,0 +1,77 @@ +import { and, eq } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { clientPages, clients } from '../db/schema.ts'; +import { intakeLink } from '../intake/service.ts'; +import type { Guards } from '../leads/routes.ts'; +import type { Config } from '../lib/config.ts'; +import { forbidden, notFound } from '../lib/errors.ts'; +import type { ImageFetcher } from './media.ts'; +import { bookViewing, ensureClientPage, loadPage, offerMatching, pagePhoto } from './service.ts'; + +const Id = z.object({ id: z.coerce.number().int().positive() }); +const PageRef = z.object({ slug: z.string().min(1).max(60), token: z.string().min(8).max(60) }); + +export function registerShowcaseRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, fetchImage?: ImageFetcher) { + type Ctx = ReturnType; + const ownClient = async (ctx: Ctx, id: number) => { + const [client] = await db.select().from(clients).where(and(eq(clients.id, id), eq(clients.companyId, ctx.company.id))); + if (!client) throw notFound('CLIENT_NOT_FOUND'); + if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT'); + return client; + }; + + /** Ссылка агента на анкету в клиентском боте. url = null — клиентский бот не настроен. */ + app.get('/api/intake-link', async (req) => { + const ctx = g.member(req); + return { url: await intakeLink(db, config, ctx.user) }; + }); + + /** + * Страница подборки для клиента. С `match: true` перед этим к клиенту прикрепляются подходящие + * объекты — столько, сколько нашлось (до 10). + */ + app.post('/api/clients/:id/page', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { match } = z.object({ match: z.boolean().default(false) }).parse(req.body ?? {}); + const client = await ownClient(ctx, id); + return db.transaction(async (tx) => { + const added = match ? await offerMatching(tx, ctx, client) : 0; + return { url: await ensureClientPage(tx, config, ctx.company, client, ctx.user.id), added }; + }); + }); + + /** Закрыть страницу: ссылка перестаёт открываться. */ + app.delete('/api/clients/:id/page', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + await ownClient(ctx, id); + await db.update(clientPages).set({ revokedAt: new Date() }).where(eq(clientPages.clientId, id)); + return { ok: true }; + }); + + // --- без входа: страница, которую открывает клиент --- + + app.get('/api/public/pages/:slug/:token', async (req) => { + const { slug, token } = PageRef.parse(req.params); + return loadPage(db, slug, token); + }); + + /** Фото объекта: обрезанное, отдаётся самим сервисом. */ + app.get('/api/public/pages/:slug/:token/photos/:leadId/:index', async (req, reply) => { + const { slug, token } = PageRef.parse(req.params); + const { leadId, index } = z.object({ leadId: z.coerce.number().int().positive(), index: z.coerce.number().int().min(0).max(50) }).parse(req.params); + const photo = await pagePhoto(db, config, slug, token, leadId, index, fetchImage); + return reply.header('content-type', 'image/jpeg').header('cache-control', 'public, max-age=604800').send(photo); + }); + + app.post('/api/public/pages/:slug/:token/viewings', async (req) => { + const { slug, token } = PageRef.parse(req.params); + const body = z + .object({ leadId: z.number().int().positive(), day: z.iso.date(), note: z.string().trim().max(300).optional() }) + .parse(req.body); + return bookViewing(db, slug, token, { leadId: body.leadId, day: body.day, note: body.note || undefined }); + }); +} diff --git a/apps/api/src/showcase/service.ts b/apps/api/src/showcase/service.ts new file mode 100644 index 0000000..3a7f817 --- /dev/null +++ b/apps/api/src/showcase/service.ts @@ -0,0 +1,361 @@ +import { fitsHousehold, matchesClientRequest, type ClientKind, type Currency, type DealType, type ListingFeatures } from '@lr/shared'; +import { and, desc, eq, inArray, isNull, notInArray, sql } from 'drizzle-orm'; +import { newToken } from '../auth/sessions.ts'; +import type { Db, Tx } from '../db/client.ts'; +import { clientLeads, clientPages, clients, companies, events, leads, listings, pipelineStatuses, users, viewings } from '../db/schema.ts'; +import { loadParserSettings } from '../parser/settings.ts'; +import { croppedPhoto, type ImageFetcher } from './media.ts'; +import { renderShowcaseText, showcaseText } from './text.ts'; +import { canSee, inDepartment, listingLive, type Actor } from '../leads/service.ts'; +import type { Config } from '../lib/config.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import { outbox } from '../db/schema.ts'; +import { enqueueText } from '../telegram/outbox.ts'; + +// Страница подборки для клиента: объекты, которые агент ему предложил, и запись на показ. +// Открывается без входа по адресу <кабинет>/<компания>/<код>; код знает только клиент. + +/** Сколько фото показываем клиенту (как в боте list-am-bot). */ +const MAX_PHOTOS = 20; +const isListAmPhoto = (url: string) => /^https:\/\/(?:img|s)\.list\.am\//.test(url); + +type Company = typeof companies.$inferSelect; +type Client = typeof clients.$inferSelect; +type Lead = typeof leads.$inferSelect; + +const TRANSLIT: Record = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', + п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya', + ա: 'a', բ: 'b', գ: 'g', դ: 'd', ե: 'e', զ: 'z', է: 'e', ը: 'y', թ: 't', ժ: 'zh', ի: 'i', լ: 'l', խ: 'kh', ծ: 'ts', կ: 'k', հ: 'h', + ձ: 'dz', ղ: 'gh', ճ: 'ch', մ: 'm', յ: 'y', ն: 'n', շ: 'sh', ո: 'o', չ: 'ch', պ: 'p', ջ: 'j', ռ: 'r', ս: 's', վ: 'v', տ: 't', ր: 'r', + ց: 'ts', ւ: 'v', փ: 'p', ք: 'k', օ: 'o', ֆ: 'f', և: 'ev', +}; + +/** Адреса, занятые разделами кабинета: компании их взять не могут. */ +const RESERVED = new Set([ + 'api', 'assets', 'login', 'register', 'forgot-password', 'reset-password', 'verify-email', 'invite', 'leads', 'clients', 'archive', + 'analytics', 'filters', 'team', 'billing', 'settings', 'admin', 'invoices', 'messages', 'support', 'help', 'listings', 'parser', 'telegram', +]); + +/** «Арарат Недвижимость» → «ararat-nedvizhimost». */ +export function slugify(name: string): string { + // Армянское «ու» — один звук «у», а не «о» + «в». + return [...name.toLowerCase().replaceAll('ու', 'u')] + .map((ch) => TRANSLIT[ch] ?? ch) + .join('') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) + .replace(/-+$/, ''); +} + +export const validSlug = (slug: string) => /^[a-z0-9][a-z0-9-]{1,39}$/.test(slug) && !RESERVED.has(slug); + +/** Свободный адрес для компании: из названия, при совпадении — с номером. */ +export async function freeSlug(tx: Tx, name: string, companyId: number): Promise { + const base = slugify(name); + const candidates = validSlug(base) ? [base, `${base}-${companyId}`] : []; + for (const slug of [...candidates, `c${companyId}`]) { + const [taken] = await tx.select({ id: companies.id }).from(companies).where(eq(companies.slug, slug)); + if (!taken || taken.id === companyId) return slug; + } + return `c${companyId}`; +} + +export async function ensureSlug(tx: Tx, company: Company): Promise { + if (company.slug) return company.slug; + const slug = await freeSlug(tx, company.name, company.id); + await tx.update(companies).set({ slug }).where(eq(companies.id, company.id)); + return slug; +} + +/** Объекты компании, подходящие под запрос клиента и его жильцов, которые агент вправе видеть. */ +export async function matchingLeads(tx: Tx, actor: Actor, client: Client): Promise<{ lead: Lead; assigneeName: string | null }[]> { + const closed = tx + .select({ id: pipelineStatuses.id }) + .from(pipelineStatuses) + .where( + and( + eq(pipelineStatuses.companyId, actor.company.id), + eq(pipelineStatuses.pipeline, 'object'), + inArray(pipelineStatuses.code, ['deal', 'rejected']), + ), + ); + const linked = tx.select({ id: clientLeads.leadId }).from(clientLeads).where(eq(clientLeads.clientId, client.id)); + const rows = await tx + .select({ lead: leads, assigneeName: users.name }) + .from(leads) + .leftJoin(users, eq(users.id, leads.assigneeId)) + .where( + and( + eq(leads.companyId, actor.company.id), + // Подбор — из объектов отдела клиента; снятые с List.am не предлагаются. + inDepartment(leads.departmentId, client.departmentId), + listingLive, + notInArray(leads.statusId, closed), + notInArray(leads.id, linked), + ), + ) + .orderBy(desc(leads.createdAt)) + .limit(1000); + return rows.filter( + ({ lead }) => + canSee(actor, lead) && + matchesClientRequest( + { ...lead, dealType: lead.dealType as DealType | null, currency: lead.currency as Currency | null, isAgency: false }, + client.kind as ClientKind, + client.request, + ) && + // Объявление, где прямо сказано «с детьми нельзя» или «с животными нельзя», таким жильцам не предлагается. + fitsHousehold(lead.features, client.household), + ); +} + +/** Прикрепляет к клиенту подходящие объекты отметкой «предложен». Возвращает, сколько прикреплено. */ +export async function offerMatching(tx: Tx, actor: Actor, client: Client, limit = 10): Promise { + const picked = (await matchingLeads(tx, actor, client)).slice(0, limit); + await refreshStaleGallery(tx, picked.map(({ lead }) => lead.id)); + for (const { lead } of picked) { + await tx.insert(clientLeads).values({ clientId: client.id, leadId: lead.id, state: 'offered' }).onConflictDoNothing(); + await tx.insert(events).values([ + { companyId: client.companyId, clientId: client.id, userId: actor.user.id, type: 'offered', newValue: lead.title }, + { companyId: client.companyId, leadId: lead.id, userId: actor.user.id, type: 'client_offered', newValue: client.name }, + ]); + } + return picked.length; +} + +/** Ссылка на страницу клиента: на своём домене компании, если он задан, иначе на адресе сервиса. */ +export const pageUrl = (config: Config, company: Pick, slug: string, token: string) => + `${company.settings.clientHost ? `https://${company.settings.clientHost}` : config.appUrl}/${slug}/${token}`; + +/** Ссылка на страницу подборки клиента: одна на клиента, создаётся при первом запросе. */ +export async function ensureClientPage(tx: Tx, config: Config, company: Company, client: Client, createdById: number): Promise { + const slug = await ensureSlug(tx, company); + const [page] = await tx + .insert(clientPages) + .values({ token: newToken().slice(0, 22), companyId: company.id, clientId: client.id, createdById }) + .onConflictDoUpdate({ target: clientPages.clientId, set: { revokedAt: null } }) + .returning(); + return pageUrl(config, company, slug, page!.token); +} + +async function openPage(tx: Tx, slug: string, token: string) { + const [row] = await tx + .select({ page: clientPages, company: companies, client: clients, agent: users }) + .from(clientPages) + .innerJoin(companies, eq(companies.id, clientPages.companyId)) + .innerJoin(clients, eq(clients.id, clientPages.clientId)) + .innerJoin(users, eq(users.id, clientPages.createdById)) + .where(and(eq(clientPages.token, token), eq(companies.slug, slug), isNull(clientPages.revokedAt))); + if (!row) throw notFound('PAGE_NOT_FOUND'); + return row; +} + +/** Объекты страницы — те, что прикреплены к клиенту и ещё не закрыты сделкой или отказом. */ +async function pageLeads(tx: Tx, companyId: number, clientId: number) { + return tx + .select({ lead: leads, day: viewings.day }) + .from(clientLeads) + .innerJoin(leads, eq(leads.id, clientLeads.leadId)) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .leftJoin(viewings, and(eq(viewings.leadId, leads.id), eq(viewings.clientId, clientId))) + .where( + and( + eq(clientLeads.clientId, clientId), + eq(leads.companyId, companyId), + sql`${pipelineStatuses.code} is distinct from 'deal' and ${pipelineStatuses.code} is distinct from 'rejected'`, + // Снятый с List.am объект клиенту больше не показывается. + listingLive, + ), + ) + .orderBy(desc(clientLeads.createdAt)); +} + +/** + * Что видит клиент. Точного адреса, телефона и ссылки на объявление здесь нет намеренно: + * показ и контакт с собственником идут через агента. + */ +export async function loadPage(db: Db, slug: string, token: string) { + const { company, client, agent } = await openPage(db, slug, token); + const rows = await pageLeads(db, company.id, client.id); + const photoBase = `/api/public/pages/${slug}/${token}/photos`; + return { + company: company.name, + agent: agent.name, + client: client.name, + objects: rows.map(({ lead: l, day }) => ({ + id: l.id, + // Названия объявления здесь нет: на List.am в нём обычно стоит улица. + /** Текст объекта по шаблону; если его дополнила модель — сохранённый вариант. */ + text: l.showcaseText ?? renderShowcaseText(l), + dealType: l.dealType, + propertyType: l.propertyType, + region: l.region, + district: l.district, + price: l.price, + currency: l.currency, + rooms: l.rooms, + area: l.area, + floor: l.floor, + floorsTotal: l.floorsTotal, + /** Фото отдаёт сам сервис — уже обрезанными; у объекта, заведённого вручную, фото остаются как есть. */ + photos: l.photos.slice(0, MAX_PHOTOS).map((src, i) => (isListAmPhoto(src) ? `${photoBase}/${l.id}/${i}` : src)), + /** День, на который клиент уже записался на показ этого объекта. */ + viewingDay: day, + })), + }; +} + +/** Обрезанное фото объекта со страницы клиента. */ +export async function pagePhoto(db: Db, config: Config, slug: string, token: string, leadId: number, index: number, fetcher?: ImageFetcher) { + const { company, client } = await openPage(db, slug, token); + const row = (await pageLeads(db, company.id, client.id)).find((r) => r.lead.id === leadId); + const url = row?.lead.photos[index]; + if (!url || index >= MAX_PHOTOS || !isListAmPhoto(url)) throw notFound(); + // Фото могли убрать с сайта вместе с объявлением — для клиента это просто «нет фото». + return croppedPhoto(config, url, fetcher).catch(() => { + throw notFound(); + }); +} + +/** + * Готовит объекты к показу клиенту: загружает полный набор фото объявления (в базе парсера только + * обложка) и собирает текст. + */ +export async function runShowcaseJobs({ db, config, source, limit = 3 }: { db: Db; config: Config; source: { gallery?(externalId: string): Promise<{ photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean }>; configure?(apiKey: string | null | undefined): void } | null; limit?: number }) { + // В очереди: объекты на открытых страницах клиентов и те, чью карточку открывал агент. + const rows = await db + .select({ lead: leads, externalId: listings.externalId }) + .from(leads) + .leftJoin(listings, eq(listings.id, leads.listingId)) + .where( + and( + isNull(leads.galleryAt), + sql`${leads.galleryTries} < 3`, + sql`(${leads.galleryWantedAt} is not null or exists ( + select 1 from client_leads cl join client_pages p on p.client_id = cl.client_id + where cl.lead_id = "leads"."id" and p.revoked_at is null))`, + ), + ) + .orderBy(sql`${leads.galleryWantedAt} desc nulls last`, leads.id) + .limit(limit); + if (!rows.length) return 0; + // Ключ ридера берётся из настроек парсера — как при обычном проходе. + const settings = await loadParserSettings(db); + source?.configure?.(settings.useKey ? settings.jinaApiKey || undefined : null); + let done = 0; + for (const { lead, externalId } of rows) { + try { + // У объекта, заведённого вручную, объявления нет: готовим только текст. + const page: { photos: string[]; features?: ListingFeatures; description?: string; removed?: boolean } = + externalId && source?.gallery ? await source.gallery(externalId) : { photos: [] }; + const photos = page.photos.length ? page.photos.slice(0, MAX_PHOTOS) : lead.photos; + const features = lead.features ?? page.features ?? null; + const description = lead.description?.trim() ? lead.description : (page.description ?? lead.description); + const text = await showcaseText(config, { ...lead, features, description }); + await db.update(leads).set({ photos, features, description, showcaseText: text, galleryAt: new Date() }).where(eq(leads.id, lead.id)); + if (page.photos.length && lead.listingId) { + await db.update(listings).set({ photos, ...(page.features ? { features: page.features } : {}) }).where(eq(listings.id, lead.listingId)); + } + if (page.removed && lead.listingId) { + // Объявление сняли — отметка «Снято с List.am» ставится сразу, не дожидаясь суточной проверки. + const now = new Date(); + await db.update(listings).set({ active: false, removedAt: now, lastCheckedAt: now }).where(and(eq(listings.id, lead.listingId), eq(listings.active, true))); + } + if (page.description && lead.listingId) { + await db + .update(listings) + .set({ description: page.description }) + .where(and(eq(listings.id, lead.listingId), sql`coalesce(trim(${listings.description}), '') = ''`)); + } + done += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[showcase] объект ${lead.id}: ${message}`); + await db.update(leads).set({ galleryTries: sql`${leads.galleryTries} + 1` }).where(eq(leads.id, lead.id)); + if (/Ридер ответил 40[123] /.test(message)) await alertReaderKey(db, message); + // Фото не загрузились — текст от модели всё равно нужен клиенту; фото догрузятся при следующей попытке. + if (!lead.showcaseText) { + const text = await showcaseText(config, lead); + if (text !== renderShowcaseText(lead)) await db.update(leads).set({ showcaseText: text }).where(eq(leads.id, lead.id)); + } + } + } + return done; +} + +/** + * Объект добавили на страницу клиента: если фото загружались больше суток назад, они загружаются + * заново — владелец мог их заменить. До новой загрузки клиент видит прежние фото и текст. + */ +export async function refreshStaleGallery(tx: Tx, leadIds: number[], now = new Date()) { + if (!leadIds.length) return; + await tx + .update(leads) + .set({ galleryAt: null, galleryTries: 0, galleryWantedAt: now }) + .where(and(inArray(leads.id, leadIds), sql`${leads.listingId} is not null`, sql`${leads.galleryAt} < ${new Date(now.getTime() - 86_400_000).toISOString()}::timestamptz`)); +} + +/** Когда суперадмины последний раз получили алерт об отклонённом ключе ридера. */ +let readerKeyAlertAt = 0; + +/** Ключ ридера отклонён (кончился баланс или ключ отозван): фото для клиентов не загружаются. Алерт не чаще раза в 6 часов. */ +async function alertReaderKey(db: Db, error: string) { + if (Date.now() - readerKeyAlertAt < 6 * 3_600_000) return; + readerKeyAlertAt = Date.now(); + const admins = await db.select({ id: users.id }).from(users).where(eq(users.role, 'superadmin')); + await enqueueText(db, admins.map((a) => a.id), 'parserFailed', [`ключ ридера Jina отклонён — фото для клиентов не загружаются. Проверьте баланс и ключ в настройках парсера. (${error.slice(0, 120)})`]); +} + +const ymd = (d: Date) => new Date(d.getTime() + 4 * 3_600_000).toISOString().slice(0, 10); + +/** + * Клиент записался на показ. Создателю ссылки уходит уведомление в бот, запись появляется в карточках + * клиента и объекта; если у клиента не стояло напоминание — оно ставится на утро дня показа. + */ +export async function bookViewing(db: Db, slug: string, token: string, input: { leadId: number; day: string; note?: string }, now = new Date()) { + const today = ymd(now); + const last = ymd(new Date(now.getTime() + 90 * 86_400_000)); + if (input.day < today || input.day > last) throw badRequest('BAD_DAY'); + return db.transaction(async (tx) => { + const { page, company, client } = await openPage(tx, slug, token); + const row = (await pageLeads(tx, company.id, client.id)).find((r) => r.lead.id === input.leadId); + if (!row) throw notFound('OBJECT_NOT_FOUND'); + const changed = row.day !== input.day; + await tx + .insert(viewings) + .values({ companyId: company.id, clientId: client.id, leadId: input.leadId, day: input.day, note: input.note ?? null }) + .onConflictDoUpdate({ target: [viewings.clientId, viewings.leadId], set: { day: input.day, note: input.note ?? null } }); + if (!changed) return { day: input.day }; + await tx.insert(events).values([ + { companyId: company.id, clientId: client.id, userId: null, type: 'viewing', newValue: row.lead.title, text: input.day }, + { companyId: company.id, leadId: input.leadId, userId: null, type: 'client_viewing', newValue: client.name, text: input.day }, + ]); + if (!client.nextActionAt || client.nextActionAt < now) { + await tx + .update(clients) + .set({ nextActionAt: new Date(`${input.day}T09:00:00+04:00`), nextActionNote: row.lead.title.slice(0, 200), nextActionNotifiedAt: null }) + .where(eq(clients.id, client.id)); + } + await tx.update(companies).set({ lastActivityAt: now }).where(eq(companies.id, company.id)); + // Уведомление — тому, кто создал ссылку; если его уже нет в компании, ответственному за клиента. + const [creator] = await tx.select().from(users).where(and(eq(users.id, page.createdById), isNull(users.removedAt))); + await tx.insert(outbox).values({ + kind: 'text', + userId: creator?.id ?? client.assigneeId, + leadId: input.leadId, + payload: { key: 'viewingBooked', args: [client.name, row.lead.title, input.day, input.note ?? ''] }, + }); + return { day: input.day }; + }); +} + +/** Записи на показ в карточке клиента. */ +export const clientViewings = (tx: Tx, clientId: number) => + tx + .select({ leadId: viewings.leadId, day: viewings.day, note: viewings.note, title: leads.title }) + .from(viewings) + .innerJoin(leads, eq(leads.id, viewings.leadId)) + .where(eq(viewings.clientId, clientId)) + .orderBy(viewings.day); diff --git a/apps/api/src/showcase/text.ts b/apps/api/src/showcase/text.ts new file mode 100644 index 0000000..5b7fd13 --- /dev/null +++ b/apps/api/src/showcase/text.ts @@ -0,0 +1,129 @@ +import type { ListingFeatures } from '@lr/shared'; +import type { Config } from '../lib/config.ts'; + +// Текст объекта для страницы клиента. Логика перенесена из бота list-am-bot (template/render.py и +// llm/openrouter_client.py): карточка по фиксированному шаблону; если задан ключ OpenRouter — тот же +// шаблон заполняет модель и добавляет абзац из описания, переписанный нейтральным тоном. +// Отличие от бота: адреса в тексте нет — показ и контакт с собственником идут через агента. + +export interface ShowcaseSource { + title: string; + description: string | null; + dealType: string | null; + district: string | null; + region: string | null; + price: number | null; + currency: string | null; + rooms: number | null; + area: number | null; + floor: number | null; + floorsTotal: number | null; + features: ListingFeatures | null; +} + +/** Приоритетные удобства — первыми в списке оснащения. */ +const AMENITY_PRIORITY = ['кондиционер', 'стиральная машина', 'посудомоечная машина', 'интернет', 'телевизор', 'холодильник', 'лифт', 'домофон', 'консьерж']; +const CURRENCY: Record = { AMD: 'драм', USD: '$', EUR: '€', RUB: '₽' }; + +/** 600000 AMD → «600.000 драм»; 3000 USD → «$3.000». */ +export function formatPrice(price: number | null, currency: string | null): string | null { + if (price == null) return null; + const amount = Math.round(price).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.'); + const cur = CURRENCY[currency ?? ''] ?? currency ?? ''; + return cur === '$' || cur === '€' ? `${cur}${amount}` : `${amount} ${cur}`.trim(); +} + +const attr = (f: ListingFeatures | null, label: string) => f?.attrs.find((a) => a.label === label)?.value; + +function amenities(f: ListingFeatures | null): string[] { + // Виды из окон и парковка — не оснащение квартиры. + const items = (f?.amenities ?? []).filter((g) => !/вид|парковк/i.test(g.group)).flatMap((g) => g.items); + const rank = (name: string) => { + const i = AMENITY_PRIORITY.findIndex((key) => name.toLowerCase().includes(key)); + return i === -1 ? AMENITY_PRIORITY.length : i; + }; + return [...new Set(items)].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'ru')).slice(0, 12); +} + +/** Карточка по шаблону. Полей, которых нет в объявлении, в тексте тоже нет. */ +export function renderShowcaseText(l: ShowcaseSource): string { + const f = l.features; + const lines: string[] = [`🏡 ${l.district ?? l.region ?? l.title}`, '']; + + const building = [/^(да|есть)$/i.test(attr(f, 'Новостройка') ?? '') ? 'Новостройка' : null, attr(f, 'Тип здания')].filter(Boolean).join(', '); + const condition = attr(f, 'Ремонт'); + if (building) lines.push(`🏢 Тип дома: ${building}`); + if (condition) lines.push(`🆕 Состояние: ${condition}`); + if (building || condition) lines.push(''); + + const specs = [ + l.area != null ? `📐 Площадь: ${l.area} м²` : null, + l.rooms != null ? `🛏 Комнат: ${l.rooms}` : null, + attr(f, 'Количество санузлов') ? `🚿 Санузлов: ${attr(f, 'Количество санузлов')}` : null, + l.floor != null ? `🏙 Этаж: ${l.floor}${l.floorsTotal ? `/${l.floorsTotal}` : ''}` : null, + ].filter((x): x is string => Boolean(x)); + if (specs.length) lines.push(...specs, ''); + + const equipment = amenities(f); + if (equipment.length) lines.push('⚙️ Оснащение:', ...equipment.map((item) => `✅ ${item}`), ''); + + const children = attr(f, 'Можно с детьми'); + const pets = attr(f, 'Можно с животными'); + if (children) lines.push(`🧑‍🧑‍🧒 Дети: ${children}`); + if (pets) lines.push(`🐶 Животные: ${pets}`); + if (children || pets) lines.push(''); + + const price = formatPrice(l.price, l.currency); + if (price) lines.push(`💵 Цена: ${price}`, ''); + + const availability = attr(f, 'Статус доступности'); + if (availability) lines.push(`📅 ${/^свободно сейчас$/i.test(availability) ? 'Готова к заселению' : availability}`); + + while (lines.at(-1) === '') lines.pop(); + return lines.join('\n'); +} + +const SYSTEM_PROMPT = `Ты — редактор объявлений о недвижимости в Армении. + +Тебе дают готовую карточку объекта и исходное описание с сайта объявлений. +Верни карточку БЕЗ ИЗМЕНЕНИЙ и, если в описании есть 1–2 предложения полезной информации +(вид, инфраструктура, особенности), добавь после карточки пустую строку и короткий абзац, +переписанный нейтрально-деловым тоном на русском языке. + +Правила: +1. Карточку не меняй: ни строки, ни цифры, ни значки. +2. Ничего не выдумывай. Не повторяй то, что уже есть в карточке. +3. Не указывай адрес, улицу, номер дома, телефоны, имена, ссылки и название сайта. +4. Если в описании нет ничего полезного — верни только карточку. +5. Ответ — только готовый текст, без пояснений и без Markdown.`; + +/** Тот же текст, дополненный абзацем из описания. Бросает ошибку, если модель недоступна. */ +async function rewrite(config: Config, card: string, description: string, fetchFn: typeof fetch): Promise { + const res = await fetchFn('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { authorization: `Bearer ${config.openRouterKey}`, 'content-type': 'application/json', 'http-referer': config.appUrl, 'x-title': 'Y&P Lead Radar' }, + body: JSON.stringify({ + model: config.openRouterModel, + temperature: 0.3, + max_tokens: 700, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: `Карточка:\n${card}\n\nОписание с сайта:\n${description.slice(0, 3000)}` }, + ], + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`OpenRouter ответил ${res.status}`); + const data = (await res.json()) as { choices?: { message?: { content?: string } }[] }; + const text = data.choices?.[0]?.message?.content?.trim(); + // Модель обязана сохранить карточку: если шапки нет, ответу не доверяем. + if (!text || !text.startsWith(card.split('\n')[0]!)) throw new Error('модель вернула не карточку'); + return text.slice(0, 4000); +} + +/** Текст для страницы клиента: с ключом OpenRouter — с абзацем от модели, иначе (и при сбое) — по шаблону. */ +export async function showcaseText(config: Config, lead: ShowcaseSource, fetchFn: typeof fetch = fetch): Promise { + const card = renderShowcaseText(lead); + if (!config.openRouterKey || !lead.description?.trim()) return card; + return rewrite(config, card, lead.description, fetchFn).catch(() => card); +} diff --git a/apps/api/src/support.int.test.ts b/apps/api/src/support.int.test.ts new file mode 100644 index 0000000..80a2b86 --- /dev/null +++ b/apps/api/src/support.int.test.ts @@ -0,0 +1,213 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashPassword } from './auth/password.ts'; +import { createSession } from './auth/sessions.ts'; +import { users } from './db/schema.ts'; +import { handleUpdate } from './telegram/bot.ts'; +import { processOutbox } from './telegram/outbox.ts'; +import { fakeTelegram, testApp } from './testing.ts'; + +// Поддержка: сообщение боту → обращение → переписка из админки → «Проблема решена». + +let t: Awaited>; +beforeAll(async () => { t = await testApp('support'); }); +afterAll(() => t.close()); + +const tg = fakeTelegram(); +const flush = async () => { while ((await processOutbox({ db: t.db, tg: tg.tg, config: t.config, sleep: async () => {} })) > 0); }; +let updateId = 0; +const write = async (chat: number, message: Record, name = 'Гость') => { + await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { + update_id: ++updateId, + message: { chat: { id: chat }, from: { id: chat, first_name: name, username: `u${chat}`, language_code: 'ru' }, ...message }, + }); + await flush(); +}; +const texts = (chat: number) => tg.inbox(chat).map((m) => m.text); + +const ADMIN_CHAT = 7001; +let admin: ReturnType | null = null; +/** Суперадмин с привязанным Telegram — один на все сценарии. */ +const superadmin = async () => { + if (admin) return admin; + const [u] = await t.db + .insert(users) + .values({ name: 'Админ', role: 'superadmin', email: 'sa@test.am', passwordHash: await hashPassword('x'), telegramId: ADMIN_CHAT }) + .returning(); + return (admin = t.client(await createSession(t.db, u!.id))); +}; + +describe('поддержка', () => { + it('сообщение боту открывает обращение; переписка идёт до «Проблема решена»', async () => { + const sa = await superadmin(); + // Пользователь с аккаунтом и привязанным Telegram. + const member = await t.register('Арам'); + const me = (await member.get('/api/me')).body.user; + await t.db.update(users).set({ telegramId: 7100 }).where(eq(users.id, me.id)); + + await write(7100, { text: 'Не приходят объекты' }, 'Арам'); + expect(texts(7100)).toEqual(['Сообщение передано в поддержку. Ответим здесь, в этом чате.']); + // Суперадмин узнаёт в боте о новом обращении. + expect(texts(ADMIN_CHAT).at(-1)).toContain('Обращение в поддержку от Арам @u7100'); + expect((await sa.get('/api/admin/support/unread')).body.count).toBe(1); + + // Второе сообщение идёт в то же обращение, без повторных подтверждений. + await write(7100, { text: 'Фильтр создан вчера' }, 'Арам'); + expect(texts(7100)).toHaveLength(1); + expect(texts(ADMIN_CHAT).filter((x) => x.includes('Обращение в поддержку'))).toHaveLength(1); + + const list = (await sa.get('/api/admin/support')).body; + expect(list).toMatchObject([{ name: 'Арам', hasAccount: true, unread: true, last: 'Фильтр создан вчера', status: 'open' }]); + const id = list[0].id as number; + + const card = (await sa.get(`/api/admin/support/${id}`)).body; + expect(card.messages.map((m: any) => [m.fromSupport, m.text])).toEqual([[false, 'Не приходят объекты'], [false, 'Фильтр создан вчера']]); + // Открытие отмечает обращение прочитанным. + expect((await sa.get('/api/admin/support/unread')).body.count).toBe(0); + + expect((await sa.post(`/api/admin/support/${id}/reply`, { text: 'Проверьте район в <фильтре>' })).status).toBe(200); + await flush(); + expect(texts(7100).at(-1)).toBe('💬 Поддержка\nПроверьте район в <фильтре>'); + + // Ответ пользователя снова поднимает обращение и уведомляет суперадмина. + await write(7100, { text: 'Спасибо, заработало' }, 'Арам'); + expect((await sa.get('/api/admin/support/unread')).body.count).toBe(1); + expect(texts(ADMIN_CHAT).filter((x) => x.includes('Обращение в поддержку'))).toHaveLength(2); + + expect((await sa.post(`/api/admin/support/${id}/close`)).status).toBe(200); + await flush(); + expect(texts(7100).at(-1)).toContain('Обращение закрыто'); + expect((await sa.get('/api/admin/support')).body).toHaveLength(0); + expect((await sa.get('/api/admin/support?status=closed')).body).toMatchObject([{ id, status: 'closed' }]); + expect((await sa.post(`/api/admin/support/${id}/reply`, { text: 'ещё' })).body.code).toBe('TICKET_CLOSED'); + + // Новое сообщение после закрытия — новое обращение. + await write(7100, { text: 'Ещё вопрос' }, 'Арам'); + const again = (await sa.get('/api/admin/support')).body; + expect(again).toHaveLength(1); + expect(again[0].id).not.toBe(id); + + // Раздел только для суперадмина. + expect((await member.get('/api/admin/support')).status).toBe(403); + expect((await member.post(`/api/admin/support/${again[0].id}/reply`, { text: 'x' })).status).toBe(403); + }); + + it('пишет человек без аккаунта: обращение создаётся, ответ доходит в его чат', async () => { + const sa = await superadmin(); + await write(7200, { text: 'Сколько стоит?' }, 'Гость'); + expect(texts(7200)).toEqual(['Сообщение передано в поддержку. Ответим здесь, в этом чате.']); + const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7200'); + expect(ticket).toMatchObject({ name: 'Гость', hasAccount: false, company: null }); + await sa.post(`/api/admin/support/${ticket.id}/reply`, { text: 'Solo — 40 $ в месяц' }); + await flush(); + expect(texts(7200).at(-1)).toContain('Solo — 40 $ в месяц'); + }); + + it('суперадмин отвечает файлом: он уходит в чат и виден в переписке', async () => { + const sa = await superadmin(); + await write(7500, { text: 'Пришлите договор' }); + const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7500'); + const token = (await t.client().post('/api/auth/login', { email: 'sa@test.am', password: 'x' })).body.token as string; + const upload = (path: string, body: Buffer, auth = token) => + t.app.inject({ method: 'POST', url: path, payload: body, headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/octet-stream' } }); + const url = `/api/admin/support/${ticket.id}/file?name=${encodeURIComponent('договор.pdf')}&type=application/pdf&text=${encodeURIComponent('Вот <договор>')}`; + + const res = await upload(url, Buffer.from('PDF-DATA')); + expect(res.statusCode).toBe(200); + expect(t.sentFiles.at(-1)).toEqual({ chatId: 7500, name: 'договор.pdf', type: 'application/pdf', size: 8, caption: '💬 Поддержка\nВот <договор>' }); + await upload(`/api/admin/support/${ticket.id}/file?name=shot.png&type=image/png`, Buffer.from('PNG')); + expect(t.sentFiles.at(-1)!.caption).toBe('💬 Поддержка'); + + const card = (await sa.get(`/api/admin/support/${ticket.id}`)).body; + expect(card.messages.slice(1).map((m: any) => [m.fromSupport, m.text, m.fileKind, m.fileName])).toEqual([ + [true, 'Вот <договор>', 'file', 'договор.pdf'], + [true, null, 'photo', 'shot.png'], + ]); + // Пустой файл и чужой доступ отклоняются; в закрытое обращение файл не уходит. + expect((await upload(url, Buffer.alloc(0))).statusCode).toBe(400); + expect((await upload(url, Buffer.from('x'), 'wrong-token')).statusCode).toBe(401); + await sa.post(`/api/admin/support/${ticket.id}/close`); + expect((await upload(url, Buffer.from('x'))).json().code).toBe('TICKET_CLOSED'); + }); + + it('фото и файл попадают в обращение; команды обращением не становятся', async () => { + const sa = await superadmin(); + await write(7300, { photo: [{ file_id: 'small' }, { file_id: 'big' }], caption: 'Вот экран' }); + await write(7300, { document: { file_id: 'doc1', file_name: 'договор.pdf' } }); + await write(7300, { text: '/pause' }); + await write(7300, { text: '/start' }); + const ticket = (await sa.get('/api/admin/support')).body.find((x: any) => x.tgUsername === 'u7300'); + const card = (await sa.get(`/api/admin/support/${ticket.id}`)).body; + expect(card.messages.map((m: any) => [m.text, m.fileKind, m.fileName])).toEqual([ + ['Вот экран', 'photo', null], + [null, 'file', 'договор.pdf'], + ]); + // Сообщения в группе, куда добавили бота, игнорируются. + await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { + update_id: ++updateId, message: { chat: { id: -500 }, from: { id: 7400, first_name: 'Группа' }, text: 'привет' }, + }); + expect((await sa.get('/api/admin/support')).body.some((x: any) => x.name === 'Группа')).toBe(false); + }); + + it('приветствие бота: суперадмин задаёт текст и файлы для /start; регистрация и новая компания приходят ему в Telegram', async () => { + const sa = await superadmin(); + // По умолчанию — стандартный текст. + await write(7600, { text: '/start' }); + expect(texts(7600).at(-2)).toContain('Y&P Lead Radar'); + expect((await sa.get('/api/admin/bot-start')).body).toEqual({ text: { ru: '', hy: '', en: '' }, files: [] }); + + // Свой текст и два файла: файл сначала приходит суперадмину, у Telegram остаётся его идентификатор. + expect((await sa.patch('/api/admin/bot-start', { text: { ru: 'Здравствуйте! Вот <презентация>', hy: '', en: 'Hello!' } })).status).toBe(200); + const token = (await t.client().post('/api/auth/login', { email: 'sa@test.am', password: 'x' })).body.token as string; + const upload = (name: string, type: string, auth = token) => + t.app.inject({ method: 'POST', url: `/api/admin/bot-start/files?name=${encodeURIComponent(name)}&type=${type}`, payload: Buffer.from('DATA'), headers: { authorization: `Bearer ${auth}`, 'content-type': 'application/octet-stream' } }); + expect((await upload('презентация.pdf', 'application/pdf')).statusCode).toBe(200); + expect(t.sentFiles.at(-1)).toMatchObject({ chatId: ADMIN_CHAT, name: 'презентация.pdf', caption: '/start' }); + const saved = (await upload('logo.png', 'image/png')).json(); + expect(saved.files.map((f: any) => [f.kind, f.name])).toEqual([['file', 'презентация.pdf'], ['photo', 'logo.png']]); + expect((await upload('x.pdf', 'application/pdf', 'wrong-token')).statusCode).toBe(401); + + await write(7601, { text: '/start' }); + expect(texts(7601)).toEqual(['Здравствуйте! Вот <презентация>', `[file] ${saved.files[0].fileId}`, `[photo] ${saved.files[1].fileId}`, 'Выберите язык · Ընտրեք լեզուն · Choose a language']); + expect(tg.hasButton(tg.inbox(7601).at(-1)!, 'lang:')).toBe(true); + // Выбрал английский — бот отвечает на нём и запоминает выбор; армянского текста нет — уходит стандартный армянский. + const pick = (chat: number, code: string) => + handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: ++updateId, callback_query: { id: `l${updateId}`, from: { id: chat, first_name: 'Гость', language_code: 'ru' }, data: `lang:${code}`, message: { chat: { id: chat }, message_id: 1 } } }); + await pick(7601, 'en'); + expect(texts(7601).at(-1)).toBe('Hello!'); + await write(7601, { text: '/start' }); + expect(texts(7601).at(-4)).toBe('Hello!'); + await pick(7601, 'hy'); + expect(texts(7601).at(-1)).toContain('Lead Radar բոտն է'); + await pick(7601, 'xx'); + expect(texts(7601).at(-1)).toContain('Lead Radar բոտն է'); + // Армянского текста нет — уходит русский; английский — свой. + await handleUpdate({ db: t.db, tg: tg.tg, config: t.config }, { update_id: ++updateId, message: { chat: { id: 7602 }, from: { id: 7602, first_name: 'Guest', language_code: 'en' }, text: '/start' } }); + expect(texts(7602)[0]).toBe('Hello!'); + + // Файл убрали, текст очистили — снова стандартное приветствие. + expect((await sa.del('/api/admin/bot-start/files/0')).body.files).toHaveLength(1); + expect((await sa.del('/api/admin/bot-start/files/5')).status).toBe(404); + await sa.del('/api/admin/bot-start/files/0'); + await sa.patch('/api/admin/bot-start', { text: { ru: '', hy: '', en: '' } }); + await write(7603, { text: '/start' }); + expect(texts(7603)).toHaveLength(2); + expect(texts(7603)[0]).toContain('Это бот Y&P Lead Radar'); + // Настройки бота — только суперадмину. + const guest = await t.register('Гость'); + expect((await guest.get('/api/admin/bot-start')).status).toBe(403); + + // Регистрация и создание компании: суперадмин узнаёт, кто это и как с ним связаться. + await flush(); + const before = texts(ADMIN_CHAT).length; + const reg = await t.client().post('/api/auth/register', { name: 'Карен <Новый>', email: 'karen@test.am', password: 'password-123' }); + expect(reg.status).toBe(200); + const karen = t.client(reg.body.token); + await karen.post('/api/company', { name: 'Агентство Карена', plan: 'team', billingModel: 'fixed' }); + await flush(); + expect(texts(ADMIN_CHAT).slice(before)).toEqual([ + '🆕 Новая регистрация: Карен <Новый>\nkaren@test.am', + '🏢 Новая компания «Агентство Карена», тариф team\nРуководитель: Карен <Новый>\nkaren@test.am', + ]); + }); +}); diff --git a/apps/api/src/support/routes.ts b/apps/api/src/support/routes.ts new file mode 100644 index 0000000..339c483 --- /dev/null +++ b/apps/api/src/support/routes.ts @@ -0,0 +1,150 @@ +import { and, asc, desc, eq, sql } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { companies, supportMessages, supportTickets, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import { closeTicket, replyToTicket, replyWithFile, type FileSender } from './service.ts'; + +const Id = z.object({ id: z.coerce.number().int().positive() }); + +/** Раздел «Поддержка» суперадмина: обращения из бота, переписка, «Проблема решена». */ +export function registerSupportRoutes( + app: FastifyInstance, + db: Db, + superadmin: (req: FastifyRequest) => { user: typeof users.$inferSelect }, + config: Config, + sendFile: FileSender | null, +) { + // Файл приходит телом запроса как есть; Telegram принимает от бота до 50 МБ, здесь предел — 20. + const MAX_FILE = 20 * 1024 * 1024; + + const ticketRows = (where: ReturnType) => + db + .select({ + ticket: supportTickets, + userName: users.name, + userRole: users.role, + company: companies.name, + last: sql`(select coalesce(m.text, '📎') from support_messages m where m.ticket_id = "support_tickets"."id" order by m.id desc limit 1)`, + lastFromSupport: sql`(select m.from_support from support_messages m where m.ticket_id = "support_tickets"."id" order by m.id desc limit 1)`, + }) + .from(supportTickets) + .leftJoin(users, eq(users.id, supportTickets.userId)) + .leftJoin(companies, eq(companies.id, users.companyId)) + .where(where) + .orderBy(desc(supportTickets.lastMessageAt)) + .limit(100); + + type Row = Awaited>[number]; + const view = (r: Row) => ({ + id: r.ticket.id, + status: r.ticket.status, + unread: r.ticket.unread, + name: r.userName ?? r.ticket.tgName ?? 'Telegram', + tgUsername: r.ticket.tgUsername, + /** Есть ли у написавшего аккаунт в сервисе. */ + hasAccount: r.ticket.userId !== null, + role: r.userRole, + company: r.company, + last: r.last, + lastFromSupport: Boolean(r.lastFromSupport), + lastMessageAt: r.ticket.lastMessageAt, + createdAt: r.ticket.createdAt, + closedAt: r.ticket.closedAt, + }); + + app.get('/api/admin/support', async (req) => { + superadmin(req); + const { status } = z.object({ status: z.enum(['open', 'closed']).default('open') }).parse(req.query); + return (await ticketRows(and(eq(supportTickets.status, status)))).map(view); + }); + + /** Счётчик для меню: открытые обращения с непрочитанными сообщениями. */ + app.get('/api/admin/support/unread', async (req) => { + superadmin(req); + const [row] = await db + .select({ n: sql`count(*)::int` }) + .from(supportTickets) + .where(and(eq(supportTickets.status, 'open'), eq(supportTickets.unread, true))); + return { count: row?.n ?? 0 }; + }); + + /** Переписка по обращению. Открытие отмечает его прочитанным. */ + app.get('/api/admin/support/:id', async (req) => { + superadmin(req); + const { id } = Id.parse(req.params); + const [row] = await ticketRows(and(eq(supportTickets.id, id))); + if (!row) throw notFound(); + if (row.ticket.unread) await db.update(supportTickets).set({ unread: false }).where(eq(supportTickets.id, id)); + const messages = await db + .select({ + id: supportMessages.id, + fromSupport: supportMessages.fromSupport, + text: supportMessages.text, + fileKind: supportMessages.fileKind, + fileName: supportMessages.fileName, + at: supportMessages.at, + }) + .from(supportMessages) + .where(eq(supportMessages.ticketId, id)) + .orderBy(asc(supportMessages.id)); + return { ...view(row), unread: false, messages }; + }); + + app.post('/api/admin/support/:id/reply', async (req) => { + const ctx = superadmin(req); + const { id } = Id.parse(req.params); + const { text } = z.object({ text: z.string().trim().min(1).max(3500) }).parse(req.body); + const message = await replyToTicket(db, id, ctx.user.id, text); + return { id: message.id }; + }); + + /** Ответ с файлом: имя, тип и подпись — в адресе, сам файл — в теле запроса. */ + app.post('/api/admin/support/:id/file', { bodyLimit: MAX_FILE }, async (req) => { + const ctx = superadmin(req); + const { id } = Id.parse(req.params); + const q = z + .object({ + name: z.string().trim().min(1).max(200), + type: z.string().trim().max(100).default('application/octet-stream'), + text: z.string().trim().max(900).optional(), + }) + .parse(req.query); + if (!sendFile) throw badRequest('TELEGRAM_DISABLED'); + if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION'); + const message = await replyWithFile(db, sendFile, id, ctx.user.id, { name: q.name, type: q.type, data: req.body }, q.text || null); + return { id: message.id }; + }); + + app.post('/api/admin/support/:id/close', async (req) => { + superadmin(req); + const { id } = Id.parse(req.params); + await closeTicket(db, id); + return { ok: true }; + }); + + /** Вложение из Telegram: файл лежит у Telegram, сервис отдаёт его суперадмину, не раскрывая токен бота. */ + app.get('/api/admin/support/messages/:id/file', async (req, reply) => { + superadmin(req); + const { id } = Id.parse(req.params); + const [message] = await db.select().from(supportMessages).where(eq(supportMessages.id, id)); + if (!message?.fileId) throw notFound(); + if (!config.telegramBotToken) throw badRequest('TELEGRAM_DISABLED'); + const base = `https://api.telegram.org`; + const info = (await ( + await fetch(`${base}/bot${config.telegramBotToken}/getFile?file_id=${encodeURIComponent(message.fileId)}`) + ).json()) as { ok: boolean; result?: { file_path?: string } }; + if (!info.ok || !info.result?.file_path) throw notFound(); + const file = await fetch(`${base}/file/bot${config.telegramBotToken}/${info.result.file_path}`); + if (!file.ok) throw notFound(); + const name = message.fileName ?? info.result.file_path.split('/').pop() ?? 'file'; + const type = message.fileKind === 'photo' ? 'image/jpeg' : (file.headers.get('content-type') ?? 'application/octet-stream'); + return reply + .header('content-type', type) + .header('cache-control', 'private, max-age=3600') + .header('content-disposition', `${message.fileKind === 'photo' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`) + .send(Buffer.from(await file.arrayBuffer())); + }); +} diff --git a/apps/api/src/support/service.ts b/apps/api/src/support/service.ts new file mode 100644 index 0000000..c6122f6 --- /dev/null +++ b/apps/api/src/support/service.ts @@ -0,0 +1,169 @@ +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { outbox, supportMessages, supportTickets, users } from '../db/schema.ts'; +import { badRequest, conflict, notFound } from '../lib/errors.ts'; +import { enqueueText } from '../telegram/outbox.ts'; +import { esc, texts } from '../telegram/texts.ts'; + +// Поддержка: всё, что человек пишет боту (кроме команд), попадает в обращение. Суперадмин отвечает +// из админки — ответ уходит в тот же чат. Обращение живёт, пока суперадмин не нажмёт «Проблема решена». + +type User = typeof users.$inferSelect; + +export interface IncomingFile { + id: string; + kind: 'photo' | 'file'; + name?: string; +} + +export interface Incoming { + chatId: number; + /** Аккаунт, к которому привязан этот Telegram; null — не привязан. */ + user: User | null; + tgName: string | null; + tgUsername: string | null; + lang: string; + text: string | null; + file?: IncomingFile; +} + +/** Сообщение в чат Telegram по его номеру — для тех, у кого нет аккаунта. */ +const enqueueChat = (tx: Tx, chatId: number, userId: number | null, lang: string, key: string, args: unknown[] = []) => + tx.insert(outbox).values({ kind: 'text', userId, payload: { key, args, chatId, lang } }); + +/** Сообщение от пользователя: дописывается в открытое обращение или открывает новое. */ +export async function receiveSupportMessage(db: Db, m: Incoming): Promise<{ ticketId: number; opened: boolean }> { + return db.transaction(async (tx) => { + const [open] = await tx + .select() + .from(supportTickets) + .where(and(eq(supportTickets.chatId, m.chatId), eq(supportTickets.status, 'open'))) + .for('update'); + const ticket = + open ?? + ( + await tx + .insert(supportTickets) + .values({ chatId: m.chatId, userId: m.user?.id ?? null, tgName: m.tgName, tgUsername: m.tgUsername }) + .returning() + )[0]!; + await tx.insert(supportMessages).values({ + ticketId: ticket.id, + text: m.text, + fileId: m.file?.id ?? null, + fileKind: m.file?.kind ?? null, + fileName: m.file?.name ?? null, + }); + await tx + .update(supportTickets) + .set({ + unread: true, + lastMessageAt: new Date(), + // Человек мог привязать Telegram уже после первого сообщения. + userId: m.user?.id ?? ticket.userId, + tgName: m.tgName ?? ticket.tgName, + tgUsername: m.tgUsername ?? ticket.tgUsername, + }) + .where(eq(supportTickets.id, ticket.id)); + + if (!open) await enqueueChat(tx, m.chatId, m.user?.id ?? null, m.lang, 'supportOpened'); + // Суперадминам в бот — о новом обращении и о первом непрочитанном сообщении, а не о каждом. + if (!open || !open.unread) { + const admins = await tx + .select({ id: users.id }) + .from(users) + .where(and(eq(users.role, 'superadmin'), isNull(users.removedAt), isNotNull(users.telegramId))); + const who = [m.user?.name ?? m.tgName ?? 'Telegram', m.tgUsername ? `@${m.tgUsername}` : null].filter(Boolean).join(' '); + const snippet = (m.text ?? '📎').slice(0, 300); + await enqueueText(tx, admins.map((a) => a.id).filter((id) => id !== m.user?.id), 'supportNew', [who, snippet]); + } + return { ticketId: ticket.id, opened: !open }; + }); +} + +const openTicket = async (tx: Tx, id: number) => { + const [ticket] = await tx.select().from(supportTickets).where(eq(supportTickets.id, id)).for('update'); + if (!ticket) throw notFound(); + if (ticket.status !== 'open') throw conflict('TICKET_CLOSED'); + return ticket; +}; + +const langOf = async (tx: Tx, userId: number | null) => + userId ? ((await tx.select({ lang: users.lang }).from(users).where(eq(users.id, userId)))[0]?.lang ?? 'ru') : 'ru'; + +/** Ответ поддержки: сохраняется в обращении и уходит пользователю в бот. */ +export async function replyToTicket(db: Db, ticketId: number, authorId: number, text: string) { + return db.transaction(async (tx) => { + const ticket = await openTicket(tx, ticketId); + const [message] = await tx + .insert(supportMessages) + .values({ ticketId, fromSupport: true, authorId, text }) + .returning(); + await tx.update(supportTickets).set({ unread: false, lastMessageAt: new Date() }).where(eq(supportTickets.id, ticketId)); + await enqueueChat(tx, ticket.chatId, ticket.userId, await langOf(tx, ticket.userId), 'supportReply', [text]); + return message!; + }); +} + +export interface OutgoingFile { + name: string; + type: string; + data: Buffer; +} + +/** Отправка файла в чат Telegram. Возвращает идентификатор файла у Telegram — по нему админка показывает вложение. */ +export type FileSender = (chatId: number, file: OutgoingFile, captionHtml: string) => Promise<{ fileId: string; kind: 'photo' | 'file' }>; + +/** Отправка через Bot API: картинки до 10 МБ уходят как фото, всё остальное — как документ. */ +export const telegramFileSender = (token: string): FileSender => async (chatId, file, captionHtml) => { + const asPhoto = /^image\/(jpeg|png|webp)$/.test(file.type) && file.data.length < 10 * 1024 * 1024; + const form = new FormData(); + form.set('chat_id', String(chatId)); + form.set('caption', captionHtml); + form.set('parse_mode', 'HTML'); + form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(file.data)], { type: file.type }), file.name); + const res = await fetch(`https://api.telegram.org/bot${token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { + method: 'POST', + body: form, + signal: AbortSignal.timeout(120_000), + }); + const data = (await res.json()) as { + ok: boolean; + description?: string; + result?: { photo?: { file_id: string }[]; document?: { file_id: string }; animation?: { file_id: string }; video?: { file_id: string } }; + }; + const r = data.result; + const fileId = r?.photo?.at(-1)?.file_id ?? r?.document?.file_id ?? r?.animation?.file_id ?? r?.video?.file_id; + if (!data.ok || !fileId) throw badRequest('FILE_SEND_FAILED'); + return { fileId, kind: r?.photo ? 'photo' : 'file' }; +}; + +/** + * Ответ поддержки с файлом. Файл уходит в Telegram сразу, без очереди: так суперадмин сразу видит, + * дошёл ли он, а большой файл не лежит в базе. + */ +export async function replyWithFile(db: Db, send: FileSender, ticketId: number, authorId: number, file: OutgoingFile, text: string | null) { + const [ticket] = await db.select().from(supportTickets).where(eq(supportTickets.id, ticketId)); + if (!ticket) throw notFound(); + if (ticket.status !== 'open') throw conflict('TICKET_CLOSED'); + const t = texts(await langOf(db, ticket.userId)); + const sent = await send(ticket.chatId, file, t.supportReply(esc(text ?? '')).trim()); + const [message] = await db + .insert(supportMessages) + .values({ ticketId, fromSupport: true, authorId, text, fileId: sent.fileId, fileKind: sent.kind, fileName: file.name }) + .returning(); + await db.update(supportTickets).set({ unread: false, lastMessageAt: new Date() }).where(eq(supportTickets.id, ticketId)); + return message!; +} + +/** «Проблема решена»: обращение закрывается, пользователь получает об этом сообщение. */ +export async function closeTicket(db: Db, ticketId: number) { + await db.transaction(async (tx) => { + const ticket = await openTicket(tx, ticketId); + await tx + .update(supportTickets) + .set({ status: 'closed', unread: false, closedAt: new Date() }) + .where(eq(supportTickets.id, ticketId)); + await enqueueChat(tx, ticket.chatId, ticket.userId, await langOf(tx, ticket.userId), 'supportClosed'); + }); +} diff --git a/apps/api/src/telegram/bot.ts b/apps/api/src/telegram/bot.ts new file mode 100644 index 0000000..ee0922d --- /dev/null +++ b/apps/api/src/telegram/bot.ts @@ -0,0 +1,188 @@ +import { loadBotStart, startText } from './start.ts'; +import { accessState, canWrite, type Role } from '@lr/shared'; +import { and, eq } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { botLangs, clients, companies, users } from '../db/schema.ts'; +import { intakeLink, seekerChat } from '../intake/service.ts'; +import { intakeTexts } from '../intake/texts.ts'; +import { ensureClientPage, offerMatching } from '../showcase/service.ts'; +import { AppError } from '../lib/errors.ts'; +import type { Config } from '../lib/config.ts'; +import { takeLead } from '../leads/service.ts'; +import { receiveSupportMessage } from '../support/service.ts'; +import type { Keyboard, TelegramApi, TgFrom, TgUpdate } from './client.ts'; +import { esc, texts } from './texts.ts'; + +export interface BotDeps { + db: Db; + tg: TelegramApi; + config: Config; + /** Клиентский бот: через него соискателю уходит ссылка на подборку. */ + clientTg?: TelegramApi | null; +} + +/** Вопрос о языке после приветствия: понятен на всех трёх языках, ответ — кнопкой. */ +const LANG_QUESTION = 'Выберите язык · Ընտրեք լեզուն · Choose a language'; +const LANG_KEYBOARD: Keyboard = [[ + { text: 'Русский', callback: 'lang:ru' }, + { text: 'Հայերեն', callback: 'lang:hy' }, + { text: 'English', callback: 'lang:en' }, +]]; + +const appKeyboard = (config: Config, lang: string): Keyboard | undefined => + config.appUrl.startsWith('https://') ? [[{ text: texts(lang).openApp, webApp: config.appUrl }]] : undefined; + +/** Обрабатывает одно обновление от Telegram: команды и нажатие «Беру». */ +export async function handleUpdate({ db, tg, config, clientTg }: BotDeps, update: TgUpdate): Promise { + const from: TgFrom | undefined = update.message?.from ?? update.callback_query?.from; + if (!from) return; + const [user] = await db.select().from(users).where(eq(users.telegramId, from.id)); + // Незарегистрированному бот отвечает на языке, который тот выбрал кнопкой; иначе — на языке его Telegram. + const [chosen] = user ? [] : await db.select().from(botLangs).where(eq(botLangs.telegramId, from.id)); + const lang = user?.lang ?? chosen?.lang ?? (from.language_code === 'hy' ? 'hy' : from.language_code === 'en' ? 'en' : 'ru'); + const t = texts(lang); + + if (update.callback_query) { + const cb = update.callback_query; + // Выбор языка после приветствия: запоминается и сразу применяется. + const picked = /^lang:(ru|hy|en)$/.exec(cb.data ?? '')?.[1]; + if (picked) { + if (user) await db.update(users).set({ lang: picked }).where(eq(users.id, user.id)); + else await db.insert(botLangs).values({ telegramId: from.id, lang: picked }).onConflictDoUpdate({ target: botLangs.telegramId, set: { lang: picked } }); + await tg.answerCallback(cb.id); + const chat = cb.message?.chat.id ?? from.id; + const own = user && !user.removedAt ? null : startText(await loadBotStart(db), picked, false); + const reply = user && !user.removedAt ? texts(picked).hello(esc(user.name)) : own ? esc(own) : texts(picked).start; + return void (await tg.sendMessage(chat, reply, appKeyboard(config, picked))); + } + // Ответ на анкету клиента: показать ему подходящие объекты или нет. + const match = /^(match|nomatch):(\d+)$/.exec(cb.data ?? ''); + if (match) { + if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true); + await tg.answerCallback(cb.id); + const chat = cb.message?.chat.id ?? from.id; + if (match[1] === 'nomatch') return void (await tg.sendMessage(chat, t.intakeOk)); + const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId)); + if (!company || !canWrite(accessState(company))) return void (await tg.sendMessage(chat, t.readOnly)); + const actor = { user, role: user.role as Role, company }; + const [client] = await db.select().from(clients).where(and(eq(clients.id, Number(match[2])), eq(clients.companyId, company.id))); + if (!client || (user.role !== 'manager' && client.assigneeId !== user.id)) return void (await tg.sendMessage(chat, t.unavailable)); + const { count, url } = await db.transaction(async (tx) => ({ + count: await offerMatching(tx, actor, client), + url: await ensureClientPage(tx, config, company, client, user.id), + })); + if (!count) return void (await tg.sendMessage(chat, t.matchNone)); + // Ссылку клиенту отправляет клиентский бот — в тот чат, где он заполнял анкету. + const seeker = clientTg ? await seekerChat(db, client.id) : null; + let delivered = false; + if (seeker && clientTg) { + delivered = await clientTg + .sendMessage(seeker.chatId, esc(intakeTexts(seeker.lang).selection(count, user.name, `${url}?lang=${seeker.lang}`))) + .then(() => true, () => false); + } + return void (await tg.sendMessage(chat, esc(t.matchSent(count, url, delivered)))); + } + const leadId = Number(cb.data?.match(/^take:(\d+)$/)?.[1]); + if (!Number.isInteger(leadId)) return tg.answerCallback(cb.id); + if (!user || user.removedAt || !user.companyId) return tg.answerCallback(cb.id, t.unknownUser, true); + const [company] = await db.select().from(companies).where(eq(companies.id, user.companyId)); + if (!company || !canWrite(accessState(company))) return tg.answerCallback(cb.id, t.readOnly, true); + try { + await takeLead(db, { user, role: user.role as Role, company }, leadId); + return await tg.answerCallback(cb.id, t.taken); + } catch (e) { + if (e instanceof AppError) { + return tg.answerCallback(cb.id, e.code === 'ALREADY_TAKEN' ? t.alreadyTaken : t.unavailable, true); + } + throw e; + } + } + + const msg = update.message!; + const chatId = msg.chat.id; + // Бот работает в личных чатах: в группах он молчит. + if (chatId !== from.id) return; + const text = msg.text?.trim() ?? ''; + + // Всё, что не команда, — обращение в поддержку: текст, фото, файл. + const file = msg.photo?.length + ? { id: msg.photo.at(-1)!.file_id, kind: 'photo' as const } + : msg.document + ? { id: msg.document.file_id, kind: 'file' as const, name: msg.document.file_name } + : undefined; + const body = text || msg.caption?.trim() || ''; + if (!body.startsWith('/') && (body || file)) { + await receiveSupportMessage(db, { + chatId, + user: user && !user.removedAt ? user : null, + tgName: [from.first_name, from.last_name].filter(Boolean).join(' ') || null, + tgUsername: from.username ?? null, + lang, + text: body || null, + file, + }); + return; + } + + const [command, arg] = text.split(/\s+/, 2); + + if (command === '/start' && arg) { + // Привязка Telegram к аккаунту по одноразовому коду из настроек кабинета. + const [target] = await db.select().from(users).where(eq(users.telegramLinkToken, arg)); + if (!target) return void (await tg.sendMessage(chatId, t.linkBad)); + if (user && user.id !== target.id) return void (await tg.sendMessage(chatId, t.linkUsed)); + await db + .update(users) + .set({ telegramId: from.id, telegramUsername: from.username ?? null, telegramLinkToken: null }) + .where(eq(users.id, target.id)); + return void (await tg.sendMessage(chatId, texts(target.lang).linked(esc(target.name)), appKeyboard(config, target.lang))); + } + if (!user || user.removedAt) { + // Приветствие для тех, кто ещё не в кабинете: текст и файлы задаёт суперадмин. + const custom = await loadBotStart(db); + const own = startText(custom, lang); + await tg.sendMessage(chatId, own ? esc(own) : t.start, appKeyboard(config, lang)); + for (const f of custom.files) await tg.sendFileById(chatId, f.fileId, f.kind).catch((e) => console.error('[start-file]', e)); + await tg.sendMessage(chatId, LANG_QUESTION, LANG_KEYBOARD); + return; + } + + if (command === '/anketa') { + const url = await intakeLink(db, config, user); + return void (await tg.sendMessage(chatId, url ? esc(t.anketa(url)) : t.anketaOff)); + } + if (command === '/pause' || command === '/resume') { + await db.update(users).set({ notificationsPaused: command === '/pause' }).where(eq(users.id, user.id)); + return void (await tg.sendMessage(chatId, command === '/pause' ? t.paused : t.resumed)); + } + await tg.sendMessage(chatId, t.hello(esc(user.name)), appKeyboard(config, lang)); +} + +/** Длинный опрос Bot API. Возвращает функцию остановки. */ +export function startPolling( + deps: BotDeps | { tg: TelegramApi }, + onError: (e: unknown) => void = console.error, + /** Обработчик обновлений: у основного и клиентского бота он свой. */ + handle: (update: TgUpdate) => Promise = (update) => handleUpdate(deps as BotDeps, update), +): () => void { + let stopped = false; + void (async () => { + let offset = 0; + while (!stopped) { + try { + const updates = await deps.tg.getUpdates(offset, 25); + for (const update of updates) { + offset = update.update_id + 1; + await handle(update).catch(onError); + } + } catch (e) { + if (stopped) break; + onError(e); + await new Promise((r) => setTimeout(r, 3000)); + } + } + })(); + return () => { + stopped = true; + }; +} diff --git a/apps/api/src/telegram/client.ts b/apps/api/src/telegram/client.ts new file mode 100644 index 0000000..d5fd237 --- /dev/null +++ b/apps/api/src/telegram/client.ts @@ -0,0 +1,153 @@ +/** Кнопка под сообщением: callback — нажатие обрабатывает бот; url — ссылка; webApp — открыть Mini App. */ +export type Button = { text: string } & ({ callback: string } | { url: string } | { webApp: string }); +export type Keyboard = Button[][]; + +export interface SentMessage { + chatId: number; + messageId: number; +} + +export class TelegramError extends Error { + constructor( + readonly code: number, + description: string, + /** Сколько секунд Telegram просит подождать (ошибка 429). */ + readonly retryAfter?: number, + ) { + super(description); + } + /** Повторять бессмысленно: бот заблокирован, чат не найден, неверный запрос. */ + get permanent() { + return this.code === 400 || this.code === 403; + } +} + +export interface TgUpdate { + update_id: number; + message?: { + chat: { id: number }; + from?: TgFrom; + text?: string; + /** Подпись к фото или файлу. */ + caption?: string; + /** Фото в нескольких размерах, последнее — самое большое. */ + photo?: { file_id: string }[]; + document?: { file_id: string; file_name?: string }; + /** Контакт, отправленный кнопкой «Поделиться контактом». */ + contact?: { phone_number: string; first_name?: string }; + }; + callback_query?: { id: string; from: TgFrom; data?: string; message?: { chat: { id: number }; message_id: number } }; +} +export interface TgFrom { + id: number; + first_name?: string; + last_name?: string; + username?: string; + language_code?: string; +} + +/** То, что сервису нужно от Bot API. В тестах подменяется записывающей заглушкой. */ +export interface TelegramApi { + sendMessage(chatId: number, text: string, keyboard?: Keyboard): Promise; + sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard): Promise; + /** Файл, который уже лежит у Telegram, — по его идентификатору. */ + sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file'): Promise; + /** Меняет текст (или подпись к фото) и кнопки уже отправленного сообщения. */ + editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard): Promise; + answerCallback(callbackId: string, text?: string, alert?: boolean): Promise; + getUpdates(offset: number, timeoutSec: number): Promise; +} + +const markup = (keyboard?: Keyboard) => ({ + inline_keyboard: (keyboard ?? []).map((row) => + row.map((b) => + 'callback' in b + ? { text: b.text, callback_data: b.callback } + : 'url' in b + ? { text: b.text, url: b.url } + : { text: b.text, web_app: { url: b.webApp } }, + ), + ), +}); + +export class HttpTelegram implements TelegramApi { + constructor( + private readonly token: string, + private readonly fetchFn: typeof fetch = fetch, + ) {} + + private async call(method: string, body: Record, timeoutMs = 20_000): Promise { + const res = await this.fetchFn(`https://api.telegram.org/bot${this.token}/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + const data = (await res.json()) as { + ok: boolean; + result?: T; + error_code?: number; + description?: string; + parameters?: { retry_after?: number }; + }; + if (!data.ok) { + throw new TelegramError(data.error_code ?? res.status, data.description ?? method, data.parameters?.retry_after); + } + return data.result as T; + } + + async sendMessage(chatId: number, text: string, keyboard?: Keyboard) { + const m = await this.call<{ message_id: number }>('sendMessage', { + chat_id: chatId, + text, + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + reply_markup: markup(keyboard), + }); + return { chatId, messageId: m.message_id }; + } + + async sendPhoto(chatId: number, photoUrl: string, caption: string, keyboard?: Keyboard) { + const m = await this.call<{ message_id: number }>('sendPhoto', { + chat_id: chatId, + photo: photoUrl, + caption, + parse_mode: 'HTML', + reply_markup: markup(keyboard), + }); + return { chatId, messageId: m.message_id }; + } + + async sendFileById(chatId: number, fileId: string, kind: 'photo' | 'file') { + await this.call(kind === 'photo' ? 'sendPhoto' : 'sendDocument', { chat_id: chatId, [kind === 'photo' ? 'photo' : 'document']: fileId }); + } + + async editMessage(msg: SentMessage, hasPhoto: boolean, text: string, keyboard?: Keyboard) { + const base = { chat_id: msg.chatId, message_id: msg.messageId, parse_mode: 'HTML', reply_markup: markup(keyboard) }; + try { + if (hasPhoto) await this.call('editMessageCaption', { ...base, caption: text }); + else await this.call('editMessageText', { ...base, text, link_preview_options: { is_disabled: true } }); + } catch (e) { + // Текст не изменился — для нас это успех. + if (e instanceof TelegramError && /not modified/i.test(e.message)) return; + throw e; + } + } + + async answerCallback(callbackId: string, text?: string, alert = false) { + await this.call('answerCallbackQuery', { callback_query_id: callbackId, text, show_alert: alert }); + } + + /** Кнопка меню бота открывает Mini App. Вызывается при старте, когда кабинет доступен по https. */ + async setMenuButton(text: string, url: string) { + await this.call('setChatMenuButton', { menu_button: { type: 'web_app', text, web_app: { url } } }); + } + + getUpdates(offset: number, timeoutSec: number) { + return this.call( + 'getUpdates', + { offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] }, + (timeoutSec + 10) * 1000, + ); + } +} diff --git a/apps/api/src/telegram/outbox.ts b/apps/api/src/telegram/outbox.ts new file mode 100644 index 0000000..4461ca3 --- /dev/null +++ b/apps/api/src/telegram/outbox.ts @@ -0,0 +1,207 @@ +import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; +import type { Db, Tx } from '../db/client.ts'; +import { leadNotifications, leads, outbox, pipelineStatuses, users } from '../db/schema.ts'; +import type { Config } from '../lib/config.ts'; +import { TelegramError, type Keyboard, type TelegramApi } from './client.ts'; +import { esc, leadMessage, texts } from './texts.ts'; + +// Очередь сообщений Telegram (ТЗ: «очередь для уведомлений», лимиты Bot API, повтор при ошибке). +// Сообщение сначала записывается в таблицу outbox в той же транзакции, что и изменение лида, +// поэтому не теряется при сбое; воркер отправляет его отдельно. + +type User = typeof users.$inferSelect; + +/** Сейчас у пользователя тихие часы (время Еревана, UTC+4). */ +export function inQuietHours(user: Pick, now = new Date()): boolean { + if (!user.quietFrom || !user.quietTo || user.quietFrom === user.quietTo) return false; + const d = new Date(now.getTime() + 4 * 3_600_000); + const hm = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + return user.quietFrom < user.quietTo + ? hm >= user.quietFrom && hm < user.quietTo + : hm >= user.quietFrom || hm < user.quietTo; +} + +/** Уведомление о лиде с кнопкой «Беру». Пауза и тихие часы пользователя учитываются здесь. */ +export async function enqueueLead(tx: Tx, leadId: number, recipients: User[], again = false, now = new Date()) { + const rows = recipients + .filter((u) => u.telegramId && !u.removedAt && !u.notificationsPaused && !inQuietHours(u, now)) + .map((u) => ({ kind: 'lead', userId: u.id, leadId, payload: { again } })); + if (rows.length) await tx.insert(outbox).values(rows); + return rows.length; +} + +/** Обычное сообщение пользователю. `key` — ключ текста из texts(), `args` — его параметры. */ +export async function enqueueText(tx: Tx, userIds: number[], key: string, args: unknown[] = [], leadId?: number) { + if (!userIds.length) return; + await tx.insert(outbox).values( + [...new Set(userIds)].map((userId) => ({ kind: 'text', userId, leadId: leadId ?? null, payload: { key, args } })), + ); +} + +/** Сообщение всем суперадминам с привязанным Telegram: новые регистрации и компании. */ +export async function enqueueForSuperadmins(tx: Tx, key: string, args: unknown[] = []) { + const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), isNull(users.removedAt))); + await enqueueText(tx, admins.map((u) => u.id), key, args); +} + +/** Как связаться с человеком: email и Telegram. */ +export const contactOf = (u: { email: string | null; telegramUsername: string | null; telegramId: number | null }) => + [u.email, u.telegramUsername ? `@${u.telegramUsername}` : u.telegramId ? `Telegram ID ${u.telegramId}` : null].filter(Boolean).join(' · ') || '—'; + +/** Произвольный текст (сообщения суперадмина). */ +export async function enqueueRaw(tx: Tx, userIds: number[], text: string) { + if (!userIds.length) return; + await tx.insert(outbox).values(userIds.map((userId) => ({ kind: 'text', userId, payload: { raw: text } }))); +} + +/** Лид взяли: у всех получателей обновить сообщение и убрать «Беру»; неотправленные уведомления отменить. */ +export async function enqueueTaken(tx: Tx, leadId: number, takenById: number) { + await tx + .update(outbox) + .set({ doneAt: new Date(), error: 'canceled: lead taken' }) + .where(and(eq(outbox.leadId, leadId), eq(outbox.kind, 'lead'), isNull(outbox.doneAt))); + const sent = await tx.select().from(leadNotifications).where(eq(leadNotifications.leadId, leadId)); + if (!sent.length) return; + await tx.insert(outbox).values( + sent.map((n) => ({ kind: 'edit', userId: n.userId, leadId, payload: { notificationId: n.id, takenById } })), + ); +} + +const BATCH = 20; +/** Не больше ~25 сообщений в секунду на бота (лимит Bot API — 30). */ +const GAP_MS = 40; +const MAX_ATTEMPTS = 6; + +export interface OutboxDeps { + db: Db; + tg: TelegramApi; + config: Config; + sleep?: (ms: number) => Promise; +} + +const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => { + // Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет. + if (!config.appUrl.startsWith('https://')) return []; + return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }]; +}; + +/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */ +export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Promise { + const wait = sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + // Забираем пачку и сразу отодвигаем run_at: если воркер упадёт, сообщения вернутся в очередь сами. + const jobs = await db + .update(outbox) + .set({ runAt: sql`now() + interval '2 minutes'`, attempts: sql`${outbox.attempts} + 1` }) + .where( + inArray( + outbox.id, + sql`(select id from outbox where done_at is null and run_at <= now() order by id limit ${BATCH} for update skip locked)`, + ), + ) + .returning(); + jobs.sort((a, b) => a.id - b.id); + + for (const job of jobs) { + const finish = (error: string | null = null) => + db.update(outbox).set({ doneAt: new Date(), error }).where(eq(outbox.id, job.id)); + try { + const [found] = job.userId ? await db.select().from(users).where(eq(users.id, job.userId)) : []; + // Сообщения поддержки идут прямо в чат: у написавшего может не быть аккаунта. + const direct = job.payload as { chatId?: number; lang?: string }; + if (!direct.chatId && (!found?.telegramId || found.removedAt)) { + await finish('skipped: no telegram'); + continue; + } + const user = found ?? { id: 0, lang: direct.lang ?? 'ru' }; + const chatId = direct.chatId ?? found!.telegramId!; + const t = texts(user.lang); + + if (job.kind === 'text') { + const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[] }; + // Кнопки под сообщением: подпись берётся из текстов бота на языке получателя. + const buttons: Keyboard | undefined = p.buttons?.length + ? [p.buttons.map((b) => ({ text: String((t as unknown as Record)[b.key] ?? b.key), callback: b.callback }))] + : undefined; + const fn = p.key ? (t as unknown as Record)[p.key] : undefined; + const text = + p.raw !== undefined + ? esc(p.raw) + : typeof fn === 'function' + ? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a))) + : String(fn ?? ''); + if (text) await tg.sendMessage(chatId, text, buttons ?? (job.leadId ? [openButton(config, user.lang, job.leadId)].filter((r) => r.length) : undefined)); + await finish(); + } else if (job.kind === 'lead') { + const [row] = await db + .select({ lead: leads, code: pipelineStatuses.code }) + .from(leads) + .innerJoin(pipelineStatuses, eq(pipelineStatuses.id, leads.statusId)) + .where(eq(leads.id, job.leadId!)); + // Пока сообщение ждало в очереди, лид могли взять. + if (!row || row.lead.assigneeId !== null || row.code !== 'new') { + await finish('skipped: lead not in pool'); + continue; + } + const { lead } = row; + const again = Boolean((job.payload as { again?: boolean }).again); + const text = leadMessage(lead, user.lang, again ? t.backInPool : t.newLead); + const keyboard: Keyboard = [ + [{ text: t.take, callback: `take:${lead.id}` }, ...openButton(config, user.lang, lead.id)], + ]; + const photo = lead.photos[0]; + let sent; + let hasPhoto = false; + if (photo) { + try { + sent = await tg.sendPhoto(chatId, photo, text, keyboard); + hasPhoto = true; + } catch (e) { + // Telegram не смог скачать фото — отправляем без него. Остальные ошибки обрабатываются ниже. + if (!(e instanceof TelegramError) || e.code !== 400) throw e; + } + } + sent ??= await tg.sendMessage(chatId, text, keyboard); + await db.insert(leadNotifications).values({ + leadId: lead.id, + userId: user.id, + chatId: sent.chatId, + messageId: sent.messageId, + hasPhoto, + sentAt: new Date(), + }); + await finish(); + } else if (job.kind === 'edit') { + const p = job.payload as { notificationId: number; takenById: number }; + const [n] = await db.select().from(leadNotifications).where(eq(leadNotifications.id, p.notificationId)); + const [lead] = await db.select().from(leads).where(eq(leads.id, job.leadId!)); + if (n?.chatId && n.messageId && lead) { + const [taker] = await db.select({ name: users.name }).from(users).where(eq(users.id, p.takenById)); + const footer = p.takenById === user.id ? t.youTook : `${t.takenBy(esc(taker?.name ?? '—'))}`; + const mine = p.takenById === user.id ? [openButton(config, user.lang, lead.id)].filter((r) => r.length) : []; + await tg.editMessage({ chatId: n.chatId, messageId: n.messageId }, n.hasPhoto, leadMessage(lead, user.lang, t.newLead, footer), mine); + } + await finish(); + } else { + await finish(`unknown kind ${job.kind}`); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (e instanceof TelegramError && e.code === 429) { + // Telegram просит подождать: попытку не считаем, всю пачку откладываем. + const until = sql`now() + make_interval(secs => ${(e.retryAfter ?? 5) + 1})`; + await db.update(outbox).set({ runAt: until, attempts: sql`${outbox.attempts} - 1`, error: message }).where(eq(outbox.id, job.id)); + await wait(((e.retryAfter ?? 5) + 1) * 1000); + } else if ((e instanceof TelegramError && e.permanent) || job.attempts >= MAX_ATTEMPTS) { + await finish(`failed: ${message}`); + } else { + // Повтор с нарастающей паузой: 10 с, 40 с, 90 с… + await db + .update(outbox) + .set({ runAt: sql`now() + make_interval(secs => ${job.attempts * job.attempts * 10})`, error: message }) + .where(eq(outbox.id, job.id)); + } + } + await wait(GAP_MS); + } + return jobs.length; +} diff --git a/apps/api/src/telegram/start.ts b/apps/api/src/telegram/start.ts new file mode 100644 index 0000000..2e58faa --- /dev/null +++ b/apps/api/src/telegram/start.ts @@ -0,0 +1,89 @@ +import { eq } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { settings, users } from '../db/schema.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; +import type { FileSender } from '../support/service.ts'; + +// Приветствие бота: что он пишет в ответ на /start тому, кто ещё не привязал Telegram к аккаунту, +// и какие файлы присылает следом. Суперадмин меняет это в админке без перезапуска сервиса. + +const Text = z.string().trim().max(3500).default(''); + +export const BotStart = z.object({ + /** Текст по языкам; пустой — стандартный текст бота на этом языке. */ + text: z.object({ ru: Text, hy: Text, en: Text }).default({ ru: '', hy: '', en: '' }), + /** Файлы следом за текстом. Хранится идентификатор файла у Telegram: сам файл лежит у него. */ + files: z + .array(z.object({ fileId: z.string(), kind: z.enum(['photo', 'file']), name: z.string().max(200) })) + .max(10) + .default([]), +}); +export type BotStart = z.infer; + +const KEY = 'bot_start'; +const MAX_FILES = 10; +const MAX_FILE = 20 * 1024 * 1024; + +export async function loadBotStart(db: Db): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, KEY)); + return BotStart.parse(row?.value ?? {}); +} + +async function saveBotStart(db: Db, patch: Partial): Promise { + const value = BotStart.parse({ ...(await loadBotStart(db)), ...patch }); + await db.insert(settings).values({ key: KEY, value }).onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: new Date() } }); + return value; +} + +/** + * Текст приветствия на языке собеседника; null — стандартный текст бота. Пока человек язык не выбирал, + * вместо незаполненного языка уходит русский; после выбора — только текст на выбранном языке. + */ +export const startText = (s: BotStart, lang: string, fallback = true): string | null => + s.text[lang as keyof BotStart['text']] || (fallback ? s.text.ru : '') || null; + +export function registerBotStartRoutes( + app: FastifyInstance, + db: Db, + superadmin: (req: FastifyRequest) => { user: typeof users.$inferSelect }, + sendFile: FileSender | null, +) { + app.get('/api/admin/bot-start', async (req) => { + superadmin(req); + return loadBotStart(db); + }); + + app.patch('/api/admin/bot-start', async (req) => { + superadmin(req); + const { text } = BotStart.pick({ text: true }).parse(req.body); + return saveBotStart(db, { text }); + }); + + /** + * Новый файл: имя и тип — в адресе, сам файл — в теле запроса. Чтобы получить идентификатор файла, + * бот присылает его суперадмину в Telegram — заодно видно, как файл выглядит у получателя. + */ + app.post('/api/admin/bot-start/files', { bodyLimit: MAX_FILE }, async (req) => { + const ctx = superadmin(req); + const q = z + .object({ name: z.string().trim().min(1).max(200), type: z.string().trim().max(100).default('application/octet-stream') }) + .parse(req.query); + if (!sendFile) throw badRequest('TELEGRAM_DISABLED'); + if (!ctx.user.telegramId) throw badRequest('LINK_TELEGRAM_FIRST'); + if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION'); + const current = await loadBotStart(db); + if (current.files.length >= MAX_FILES) throw badRequest('TOO_MANY_FILES'); + const sent = await sendFile(ctx.user.telegramId, { name: q.name, type: q.type, data: req.body }, '/start'); + return saveBotStart(db, { files: [...current.files, { fileId: sent.fileId, kind: sent.kind, name: q.name }] }); + }); + + app.delete('/api/admin/bot-start/files/:index', async (req) => { + superadmin(req); + const { index } = z.object({ index: z.coerce.number().int().min(0) }).parse(req.params); + const current = await loadBotStart(db); + if (!current.files[index]) throw notFound(); + return saveBotStart(db, { files: current.files.filter((_, i) => i !== index) }); + }); +} diff --git a/apps/api/src/telegram/texts.ts b/apps/api/src/telegram/texts.ts new file mode 100644 index 0000000..c687fe8 --- /dev/null +++ b/apps/api/src/telegram/texts.ts @@ -0,0 +1,247 @@ +import type { Lang } from '@lr/shared'; + +/** Тексты бота. Сообщения уходят в HTML-разметке, поэтому пользовательские строки экранируются. */ +export const esc = (v: string) => v.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + +const ru = { + take: 'Беру', + open: 'Открыть', + openApp: 'Открыть Lead Radar', + newLead: 'Новый объект', + backInPool: 'Объект снова в пуле', + youTook: '✅ Вы взяли этот объект', + takenBy: (name: string) => `Взял(а) ${name}`, + taken: 'Объект ваш', + alreadyTaken: 'Уже взят', + unavailable: 'Объект недоступен', + readOnly: 'Доступ ограничен: оплатите подписку', + unknownUser: 'Сначала войдите в Lead Radar и привяжите Telegram.', + rooms: (n: number) => `${n} комн.`, + area: (n: number) => `${n} м²`, + floor: (a: number, b?: number | null) => (b ? `этаж ${a}/${b}` : `этаж ${a}`), + sale: 'Продажа', + rent: 'Аренда', + perMonth: '/мес', + agency: 'агентство', + onListAm: 'Объявление на List.am', + linked: (name: string) => `Готово, ${name}. Telegram привязан — сюда будут приходить новые объекты.`, + linkUsed: 'Этот Telegram уже привязан к другому аккаунту.', + linkBad: 'Ссылка привязки устарела. Создайте новую в настройках кабинета.', + hello: (name: string) => `Здравствуйте, ${name}. Новые объекты будут приходить в этот чат.\n\n/anketa — ссылка на анкету для клиента\n/pause — пауза уведомлений\n/resume — включить снова\n\nНужна помощь? Просто напишите сюда — подключится поддержка.`, + signupNew: (name: string, contact: string) => `🆕 Новая регистрация: ${name}\n${contact}`, + signupCompany: (company: string, plan: string, name: string, contact: string) => `🏢 Новая компания «${company}», тариф ${plan}\nРуководитель: ${name}\n${contact}`, + start: 'Это бот Y&P Lead Radar: новые объявления List.am по вашим фильтрам. Зарегистрируйтесь в кабинете и привяжите Telegram в настройках.\n\nНужна помощь? Просто напишите сюда — подключится поддержка.', + supportOpened: 'Сообщение передано в поддержку. Ответим здесь, в этом чате.', + supportReply: (text: string) => `💬 Поддержка\n${text}`, + supportClosed: 'Обращение закрыто. Если вопрос остался — просто напишите сюда.', + supportNew: (who: string, text: string) => `🆘 Обращение в поддержку от ${who}:\n${text}`, + paused: 'Уведомления о новых объектах на паузе. /resume — включить.', + resumed: 'Уведомления о новых объектах включены.', + assigned: (title: string) => `Вам назначен объект: ${title}`, + reassignedAway: (title: string, to: string) => `Объект «${title}» передан агенту ${to}.`, + poolWarn: (title: string, hours: number) => `⏳ Объект «${title}» вернётся в пул через ${hours} ч. Обновите статус, чтобы оставить его за собой.`, + poolLost: (title: string) => `Объект «${title}» вернулся в пул: статус долго не менялся.`, + reminder: (title: string, note: string | null) => `🔔 Напоминание по объекту «${title}»${note ? `: ${note}` : ''}`, + clientAssigned: (name: string) => `Вам назначен клиент: ${name}`, + clientReassignedAway: (name: string, to: string) => `Клиент «${name}» передан агенту ${to}.`, + clientReminder: (name: string, note: string | null) => `🔔 Напоминание по клиенту «${name}»${note ? `: ${note}` : ''}`, + joined: (name: string) => `${name} присоединился к компании по приглашению.`, + limitReached: 'Достигнут лимит участников тарифа. Новые приглашения не сработают.', + parserFailed: (error: string) => `⚠️ Парсер List.am: ${error}`, + intakeDone: (name: string, kind: string) => `📝 Новая анкета: ${name} — ${kind === 'buyer' ? 'покупатель' : 'арендатор'}. Клиент добавлен в CRM и закреплён за вами.\n\nПоказать ему подходящие объекты?`, + intakeMatch: 'Показать объекты', intakeLater: 'Не сейчас', + intakeOk: 'Хорошо. Подобрать объекты можно позже в карточке клиента.', + matchSent: (n: number, url: string, delivered: boolean) => `Подобрано объектов: ${n}. Страница подборки: ${url}\n${delivered ? 'Ссылка отправлена клиенту в бот.' : 'Отправьте ссылку клиенту — в бот она ему не ушла.'}`, + matchNone: 'Подходящих объектов пока нет. Когда появятся, подберите их в карточке клиента.', + viewingBooked: (client: string, title: string, day: string, note: string) => `📅 ${client} записался на показ ${day}: ${title}${note ? `\nКомментарий: ${note}` : ''}`, + anketa: (url: string) => `Ваша ссылка на анкету:\n${url}\n\nОтправьте её клиенту или разместите в объявлении. Каждый, кто заполнит анкету, попадёт в CRM как ваш клиент.`, + anketaOff: 'Анкета пока не подключена: клиентский бот не настроен.', + allow: { children: 'С детьми', pets: 'С животными', yes: 'можно', no: 'нельзя', negotiable: 'по договорённости' }, + invoiceIssued: (amount: string) => `Выставлен счёт на ${amount}. Посмотреть и оплатить можно в разделе «Подписка».`, + paymentReceived: (until: string) => `Оплата получена. Подписка действует до ${until}.`, + subEnding: (days: number, until: string) => `Подписка заканчивается ${until} — осталось ${days} дн. Оплатите счёт в разделе «Подписка», чтобы сервис работал без перерыва.`, + subOverdue: (days: number) => `Подписка просрочена. Сервис работает ещё ${days} дн., затем уведомления о новых объектах остановятся, а CRM станет доступна только на чтение.`, + subRestricted: 'Доступ ограничен: уведомления о новых объектах не приходят, CRM работает только на чтение. После оплаты доступ вернётся сразу, данные сохранены.', + planChangeCanceled: 'Смена тарифа отменена: в компании больше участников, чем вмещает выбранный тариф.', +}; + +type Texts = typeof ru; + +const hy: Texts = { + take: 'Վերցնում եմ', + open: 'Բացել', + openApp: 'Բացել Lead Radar-ը', + newLead: 'Նոր օբյեկտ', + backInPool: 'Օբյեկտը կրկին ընդհանուր ցուցակում է', + youTook: '✅ Դուք վերցրել եք այս օբյեկտը', + takenBy: (name) => `Վերցրել է ${name}`, + taken: 'Օբյեկտը ձերն է', + alreadyTaken: 'Արդեն վերցված է', + unavailable: 'Օբյեկտը հասանելի չէ', + readOnly: 'Մուտքը սահմանափակված է. վճարեք բաժանորդագրությունը', + unknownUser: 'Նախ մուտք գործեք Lead Radar և կապեք Telegram-ը։', + rooms: (n) => `${n} սենյակ`, + area: (n) => `${n} մ²`, + floor: (a, b) => (b ? `հարկ ${a}/${b}` : `հարկ ${a}`), + sale: 'Վաճառք', + rent: 'Վարձակալություն', + perMonth: '/ամիս', + agency: 'գործակալություն', + onListAm: 'Հայտարարությունը List.am-ում', + linked: (name) => `Պատրաստ է, ${name}։ Telegram-ը կապված է. նոր օբյեկտները կգան այստեղ։`, + linkUsed: 'Այս Telegram-ն արդեն կապված է այլ հաշվի։', + linkBad: 'Կապման հղումը ժամկետանց է։ Ստեղծեք նորը կաբինետի կարգավորումներում։', + hello: (name) => `Բարև, ${name}։ Նոր օբյեկտները կգան այս չատ։\n\n/anketa — հարցաթերթիկի հղում հաճախորդի համար\n/pause — դադարեցնել ծանուցումները\n/resume — կրկին միացնել\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։`, + signupNew: (name, contact) => `🆕 Նոր գրանցում՝ ${name}\n${contact}`, + signupCompany: (company, plan, name, contact) => `🏢 Նոր ընկերություն «${company}», սակագին՝ ${plan}\nՂեկավար՝ ${name}\n${contact}`, + start: 'Սա Y&P Lead Radar բոտն է. List.am-ի նոր հայտարարությունները՝ ըստ ձեր ֆիլտրերի։ Գրանցվեք կաբինետում և կապեք Telegram-ը կարգավորումներում։\n\nՕգնությո՞ւն է պետք։ Պարզապես գրեք այստեղ՝ աջակցությունը կմիանա։', + supportOpened: 'Հաղորդագրությունը փոխանցվել է աջակցությանը։ Կպատասխանենք այստեղ՝ այս չատում։', + supportReply: (text) => `💬 Աջակցություն\n${text}`, + supportClosed: 'Դիմումը փակված է։ Եթե հարցը մնացել է՝ պարզապես գրեք այստեղ։', + supportNew: (who, text) => `🆘 Դիմում աջակցությանը՝ ${who}.\n${text}`, + paused: 'Նոր օբյեկտների ծանուցումները դադարեցված են։ /resume — միացնել։', + resumed: 'Նոր օբյեկտների ծանուցումները միացված են։', + assigned: (title) => `Ձեզ նշանակվել է օբյեկտ՝ ${title}`, + reassignedAway: (title, to) => `«${title}» օբյեկտը փոխանցվել է գործակալ ${to}-ին։`, + poolWarn: (title, hours) => `⏳ «${title}» օբյեկտը ${hours} ժամից կվերադառնա ընդհանուր ցուցակ։ Թարմացրեք կարգավիճակը՝ այն պահելու համար։`, + poolLost: (title) => `«${title}» օբյեկտը վերադարձել է ընդհանուր ցուցակ. կարգավիճակը երկար ժամանակ չի փոխվել։`, + reminder: (title, note) => `🔔 Հիշեցում «${title}» օբյեկտի վերաբերյալ${note ? `՝ ${note}` : ''}`, + clientAssigned: (name) => `Ձեզ նշանակվել է հաճախորդ՝ ${name}`, + clientReassignedAway: (name, to) => `«${name}» հաճախորդը փոխանցվել է գործակալ ${to}-ին։`, + clientReminder: (name, note) => `🔔 Հիշեցում «${name}» հաճախորդի վերաբերյալ${note ? `՝ ${note}` : ''}`, + joined: (name) => `${name}-ը միացել է ընկերությանը հրավերով։`, + limitReached: 'Սակագնի մասնակիցների սահմանաչափը լրացել է։ Նոր հրավերները չեն աշխատի։', + parserFailed: (error) => `⚠️ List.am պարսեր՝ ${error}`, + intakeDone: (name, kind) => `📝 Նոր հարցաթերթիկ՝ ${name} — ${kind === 'buyer' ? 'գնորդ' : 'վարձակալ'}։ Հաճախորդն ավելացվել է CRM և ամրագրված է Ձեզ։\n\nՑույց տա՞լ նրան համապատասխան օբյեկտները։`, + intakeMatch: 'Ցույց տալ օբյեկտները', intakeLater: 'Ոչ հիմա', + intakeOk: 'Լավ։ Օբյեկտները կարող եք ընտրել ավելի ուշ՝ հաճախորդի քարտում։', + matchSent: (n, url, delivered) => `Ընտրված օբյեկտներ՝ ${n}։ Ընտրանու էջը՝ ${url}\n${delivered ? 'Հղումն ուղարկվել է հաճախորդին բոտում։' : 'Ուղարկեք հղումը հաճախորդին. բոտով այն չի գնացել։'}`, + matchNone: 'Համապատասխան օբյեկտներ դեռ չկան։ Երբ հայտնվեն, ընտրեք դրանք հաճախորդի քարտում։', + viewingBooked: (client, title, day, note) => `📅 ${client}-ը գրանցվել է ցուցադրության ${day}-ին՝ ${title}${note ? `\nՄեկնաբանություն՝ ${note}` : ''}`, + anketa: (url) => `Ձեր հարցաթերթիկի հղումը.\n${url}\n\nՈւղարկեք այն հաճախորդին կամ տեղադրեք հայտարարության մեջ։ Յուրաքանչյուրը, ով կլրացնի հարցաթերթիկը, կհայտնվի CRM-ում որպես Ձեր հաճախորդ։`, + anketaOff: 'Հարցաթերթիկը դեռ միացված չէ. հաճախորդների բոտը կարգավորված չէ։', + allow: { children: 'Երեխաներով', pets: 'Կենդանիներով', yes: 'կարելի է', no: 'չի կարելի', negotiable: 'պայմանավորվածությամբ' }, + invoiceIssued: (amount) => `Դուրս է գրվել հաշիվ՝ ${amount}։ Դիտել և վճարել կարող եք «Բաժանորդագրություն» բաժնում։`, + paymentReceived: (until) => `Վճարումը ստացված է։ Բաժանորդագրությունը գործում է մինչև ${until}։`, + subEnding: (days, until) => `Բաժանորդագրությունն ավարտվում է ${until}-ին՝ մնացել է ${days} օր։ Վճարեք հաշիվը «Բաժանորդագրություն» բաժնում, որպեսզի ծառայությունն աշխատի առանց ընդհատման։`, + subOverdue: (days) => `Բաժանորդագրությունը ժամկետանց է։ Ծառայությունը կաշխատի ևս ${days} օր, ապա նոր օբյեկտների ծանուցումները կդադարեն, իսկ CRM-ը կլինի միայն ընթերցման համար։`, + subRestricted: 'Մուտքը սահմանափակված է. նոր օբյեկտների ծանուցումներ չեն գալիս, CRM-ը միայն ընթերցման համար է։ Վճարումից հետո մուտքը կվերականգնվի անմիջապես, տվյալները պահպանված են։', + planChangeCanceled: 'Սակագնի փոփոխությունը չեղարկվել է. ընկերությունում ավելի շատ մասնակից կա, քան թույլ է տալիս ընտրված սակագինը։', +}; + +const en: Texts = { + take: 'Take', + open: 'Open', + openApp: 'Open Lead Radar', + newLead: 'New property', + backInPool: 'Property is back in the pool', + youTook: '✅ You took this property', + takenBy: (name) => `Taken by ${name}`, + taken: 'The property is yours', + alreadyTaken: 'Already taken', + unavailable: 'Property is unavailable', + readOnly: 'Access is restricted: pay the subscription', + unknownUser: 'Log in to Lead Radar and link your Telegram first.', + rooms: (n) => `${n} rooms`, + area: (n) => `${n} m²`, + floor: (a, b) => (b ? `floor ${a}/${b}` : `floor ${a}`), + sale: 'Sale', + rent: 'Rent', + perMonth: '/mo', + agency: 'agency', + onListAm: 'Listing on List.am', + linked: (name) => `Done, ${name}. Telegram is linked — new properties will arrive here.`, + linkUsed: 'This Telegram is already linked to another account.', + linkBad: 'The link has expired. Create a new one in the app settings.', + hello: (name) => `Hello, ${name}. New properties will arrive in this chat.\n\n/anketa — questionnaire link for a client\n/pause — pause notifications\n/resume — turn them back on\n\nNeed help? Just write here — support will join.`, + signupNew: (name, contact) => `🆕 New sign-up: ${name}\n${contact}`, + signupCompany: (company, plan, name, contact) => `🏢 New company “${company}”, plan ${plan}\nManager: ${name}\n${contact}`, + start: 'This is the Y&P Lead Radar bot: new List.am listings matching your filters. Sign up in the app and link Telegram in settings.\n\nNeed help? Just write here — support will join.', + supportOpened: 'Your message has been passed to support. We will reply here, in this chat.', + supportReply: (text) => `💬 Support\n${text}`, + supportClosed: 'The request is closed. If the question remains, just write here.', + supportNew: (who, text) => `🆘 Support request from ${who}:\n${text}`, + paused: 'New-property notifications are paused. /resume to turn on.', + resumed: 'New-property notifications are on.', + assigned: (title) => `A property was assigned to you: ${title}`, + reassignedAway: (title, to) => `“${title}” was reassigned to ${to}.`, + poolWarn: (title, hours) => `⏳ “${title}” returns to the pool in ${hours} h. Update the status to keep it.`, + poolLost: (title) => `“${title}” returned to the pool: the status had not changed for too long.`, + reminder: (title, note) => `🔔 Reminder for “${title}”${note ? `: ${note}` : ''}`, + clientAssigned: (name) => `A client was assigned to you: ${name}`, + clientReassignedAway: (name, to) => `Client “${name}” was reassigned to ${to}.`, + clientReminder: (name, note) => `🔔 Reminder for client “${name}”${note ? `: ${note}` : ''}`, + joined: (name) => `${name} joined the company via invitation.`, + limitReached: 'The plan member limit is reached. New invitations will not work.', + parserFailed: (error) => `⚠️ List.am parser: ${error}`, + intakeDone: (name, kind) => `📝 New questionnaire: ${name} — ${kind === 'buyer' ? 'buyer' : 'tenant'}. The client was added to the CRM and assigned to you.\n\nShow them matching properties?`, + intakeMatch: 'Show properties', intakeLater: 'Not now', + intakeOk: 'OK. You can pick properties later in the client card.', + matchSent: (n, url, delivered) => `Properties picked: ${n}. Selection page: ${url}\n${delivered ? 'The link was sent to the client in the bot.' : 'Send the link to the client — it was not delivered in the bot.'}`, + matchNone: 'No matching properties yet. When they appear, pick them in the client card.', + viewingBooked: (client, title, day, note) => `📅 ${client} booked a viewing on ${day}: ${title}${note ? `\nComment: ${note}` : ''}`, + anketa: (url) => `Your questionnaire link:\n${url}\n\nSend it to a client or put it in your ad. Everyone who fills it in appears in the CRM as your client.`, + anketaOff: 'The questionnaire is not connected yet: the client bot is not configured.', + allow: { children: 'Children', pets: 'Pets', yes: 'allowed', no: 'not allowed', negotiable: 'negotiable' }, + invoiceIssued: (amount) => `An invoice for ${amount} has been issued. You can view and pay it in “Subscription”.`, + paymentReceived: (until) => `Payment received. The subscription is valid until ${until}.`, + subEnding: (days, until) => `The subscription ends on ${until} — ${days} day(s) left. Pay the invoice in “Subscription” to keep the service running.`, + subOverdue: (days) => `The subscription is overdue. The service keeps working for ${days} more day(s); after that new-property notifications stop and the CRM becomes read-only.`, + subRestricted: 'Access is restricted: no new-property notifications, the CRM is read-only. Access returns right after payment, your data is kept.', + planChangeCanceled: 'The plan change was canceled: the company has more members than the selected plan allows.', +}; + +const all: Record = { ru, hy, en }; +export const texts = (lang: string | null | undefined): Texts => all[(lang as Lang) ?? 'ru'] ?? ru; + +const SYMBOL: Record = { USD: '$', EUR: '€', AMD: '֏', RUB: '₽' }; + +export function formatPrice(price: number | null, currency: string | null): string | null { + if (price == null) return null; + const n = Math.round(price).toLocaleString('en-US').replaceAll(',', ' '); + const sym = SYMBOL[currency ?? 'USD'] ?? ''; + return currency === 'AMD' || currency === 'RUB' ? `${n} ${sym}` : `${sym}${n}`; +} + +export interface LeadCard { + title: string; + description: string | null; + dealType: string | null; + region: string | null; + district: string | null; + address: string | null; + price: number | null; + currency: string | null; + rooms: number | null; + area: number | null; + floor: number | null; + floorsTotal: number | null; + url: string | null; + features?: { children?: 'yes' | 'no' | 'negotiable'; pets?: 'yes' | 'no' | 'negotiable' } | null; +} + +/** Текст уведомления о лиде: цена, район и адрес, комнаты, площадь, этаж, начало описания, ссылка. */ +export function leadMessage(lead: LeadCard, lang: string, header: string, footer?: string): string { + const t = texts(lang); + const price = formatPrice(lead.price, lead.currency); + const lines: string[] = [`${esc(header)}`]; + if (price) lines.push(`${price}${lead.dealType === 'rent' ? t.perMonth : ''} · ${lead.dealType === 'rent' ? t.rent : t.sale}`); + const place = [lead.district ?? lead.region, lead.address].filter(Boolean).join(', '); + if (place) lines.push(`📍 ${esc(place)}`); + const specs = [ + lead.rooms != null ? t.rooms(lead.rooms) : null, + lead.area != null ? t.area(lead.area) : null, + lead.floor != null ? t.floor(lead.floor, lead.floorsTotal) : null, + ].filter(Boolean); + if (specs.length) lines.push(specs.join(' · ')); + // Правила проживания из объявления: агенту сразу видно, подойдёт ли объект семье или хозяину животного. + const rules = (['children', 'pets'] as const) + .filter((k) => lead.features?.[k]) + .map((k) => `${t.allow[k]}: ${t.allow[lead.features![k]!]}`); + if (rules.length) lines.push(rules.join(' · ')); + const text = (lead.description ?? lead.title).replace(/\s+/g, ' ').trim(); + if (text) lines.push('', esc(text.length > 220 ? `${text.slice(0, 220)}…` : text)); + if (lead.url) lines.push('', `${t.onListAm}`); + if (footer) lines.push('', footer); + return lines.join('\n'); +} diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts new file mode 100644 index 0000000..158c5ae --- /dev/null +++ b/apps/api/src/testing.ts @@ -0,0 +1,138 @@ +import { sql } from 'drizzle-orm'; +import sharp from 'sharp'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { buildApp } from './app.ts'; +import { connect } from './db/client.ts'; +import { MIGRATIONS_DIR } from './db/migrate.ts'; +import type { PaymentProvider } from './billing/provider.ts'; +import type { PagedSource } from './ingest/service.ts'; +import type { Mail } from './lib/mailer.ts'; +import type { FileSender } from './support/service.ts'; +import type { GroupSender } from './group/service.ts'; +import type { Keyboard, SentMessage, TelegramApi } from './telegram/client.ts'; + +export const TEST_BOT_TOKEN = '123456:TEST-TOKEN'; + +/** Картинка 100 × 200, которую «скачивает» страница клиента в тестах. */ +export const testImage = await sharp({ create: { width: 100, height: 200, channels: 3, background: '#336699' } }).png().toBuffer(); + +/** + * Приложение на чистой тестовой базе. Нужен запущенный Postgres (`pnpm db:up`). + * У каждого файла тестов своя база (`name`): файлы выполняются параллельно. + */ +export async function testApp(name = 'app', source: PagedSource | null = null, payments: PaymentProvider | null = null) { + const base = process.env.TEST_DATABASE_URL ?? 'postgres://leadradar:dev-local-only@localhost:5433/postgres'; + const dbName = `leadradar_test_${name}`; + const admin = connect(base); + await admin.db.execute(sql.raw(`drop database if exists ${dbName} with (force)`)); + await admin.db.execute(sql.raw(`create database ${dbName}`)); + await admin.close(); + const url = base.replace(/\/[^/]*$/, `/${dbName}`); + const { db, close } = connect(url); + await migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + const mails: Mail[] = []; + const config = { + databaseUrl: url, + port: 0, + appUrl: 'https://app.test', + production: false, + telegramBotToken: TEST_BOT_TOKEN, + telegramBotUsername: 'test_bot', + listingSource: 'off', + cropTopPercent: 8, + mediaDir: `/tmp/leadradar-test-media-${name}`, + openRouterModel: 'test-model', + }; + /** Файлы, которые поддержка «отправила» в Telegram. */ + const sentFiles: { chatId: number; name: string; type: string; size: number; caption: string }[] = []; + const sendFile: FileSender = async (chatId, file, caption) => { + sentFiles.push({ chatId, name: file.name, type: file.type, size: file.data.length, caption }); + return { fileId: `sent-${sentFiles.length}`, kind: file.type.startsWith('image/') ? 'photo' : 'file' }; + }; + /** Что «ушло» в группу компании в Telegram. */ + const groupSent: { kind: 'album' | 'text'; chatId: number; threadId?: number; text?: string; photos?: number }[] = []; + const groupSender: GroupSender = { + album: async (bot, photos) => void groupSent.push({ kind: 'album', chatId: bot.chatId, threadId: bot.threadId, photos: photos.length }), + text: async (bot, text) => void groupSent.push({ kind: 'text', chatId: bot.chatId, threadId: bot.threadId, text }), + }; + const app = buildApp({ db, config, source, payments, sendFile, groupSender, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } }); + + /** Клиент с собственной сессией. */ + const client = (token?: string, host?: string) => { + const call = async (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: unknown) => { + const res = await app.inject({ + method, + url: path, + payload: body as object | undefined, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}) }, + }); + return { status: res.statusCode, body: res.json() as any }; + }; + return { + get: (p: string) => call('GET', p), + post: (p: string, b: unknown = {}) => call('POST', p, b), + patch: (p: string, b: unknown) => call('PATCH', p, b), + put: (p: string, b: unknown) => call('PUT', p, b), + del: (p: string) => call('DELETE', p), + }; + }; + + let n = 0; + const register = async (name = 'Пользователь') => { + const res = await client().post('/api/auth/register', { + name, + email: `u${++n}@test.am`, + password: 'password-123', + }); + return client(res.body.token as string); + }; + + return { app, db, config, mails, sentFiles, groupSent, client, register, close: async () => { await app.close(); await close(); } }; +} + +export interface FakeMessage extends SentMessage { + text: string; + photo?: string; + keyboard: Keyboard; + edited: boolean; +} + +/** Заглушка Bot API: запоминает отправленные сообщения и ответы на нажатия кнопок. */ +export function fakeTelegram() { + const messages: FakeMessage[] = []; + const answers: { id: string; text?: string }[] = []; + let nextId = 1; + /** Ошибка для следующего вызова отправки — чтобы проверить повтор. */ + let failNext: Error | null = null; + const send = (chatId: number, text: string, keyboard: Keyboard = [], photo?: string) => { + if (failNext) { + const e = failNext; + failNext = null; + throw e; + } + const m = { chatId, messageId: nextId++, text, photo, keyboard, edited: false }; + messages.push(m); + return { chatId, messageId: m.messageId }; + }; + const tg: TelegramApi = { + sendMessage: async (chatId, text, keyboard) => send(chatId, text, keyboard), + sendPhoto: async (chatId, photo, caption, keyboard) => send(chatId, caption, keyboard, photo), + sendFileById: async (chatId, fileId, kind) => void send(chatId, `[${kind}] ${fileId}`), + editMessage: async (msg, _hasPhoto, text, keyboard = []) => { + const m = messages.find((x) => x.chatId === msg.chatId && x.messageId === msg.messageId); + if (m) Object.assign(m, { text, keyboard, edited: true }); + }, + answerCallback: async (id, text) => void answers.push({ id, text }), + getUpdates: async () => [], + }; + return { + tg, + messages, + answers, + failOnce: (e: Error) => { failNext = e; }, + /** Сообщения, полученные чатом. */ + inbox: (chatId: number) => messages.filter((m) => m.chatId === chatId), + hasButton: (m: FakeMessage, callbackPrefix: string) => + m.keyboard.flat().some((b) => 'callback' in b && b.callback.startsWith(callbackPrefix)), + }; +} diff --git a/apps/api/src/watchdog.int.test.ts b/apps/api/src/watchdog.int.test.ts new file mode 100644 index 0000000..83ad2cd --- /dev/null +++ b/apps/api/src/watchdog.int.test.ts @@ -0,0 +1,51 @@ +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { outbox, parserRuns, settings, users } from './db/schema.ts'; +import { runParserWatchdog, type WatchdogState } from './ingest/watchdog.ts'; +import { saveParserSettings } from './parser/settings.ts'; +import { testApp } from './testing.ts'; + +let t: Awaited>; +beforeAll(async () => { t = await testApp('watchdog'); }); +afterAll(() => t.close()); + +const MIN = 60_000; +const sent = async (adminId: number) => + (await t.db.select().from(outbox).where(eq(outbox.userId, adminId))).map((m) => (m.payload as { raw: string }).raw); + +describe('сторож парсера', () => { + it('пишет суперадмину о простое, напоминает раз в 6 часов и сообщает о восстановлении', async () => { + const [admin] = await t.db.insert(users).values({ name: 'Юрий', role: 'superadmin', telegramId: 777 }).returning(); + const state: WatchdogState = { alertedAt: null }; + const now = new Date(); + + // Проход был 5 минут назад — всё в порядке. + await t.db.insert(parserRuns).values({ startedAt: new Date(now.getTime() - 5 * MIN), status: 'ok' }); + await runParserWatchdog(t.db, state, now); + expect(await sent(admin!.id)).toEqual([]); + + // 25 минут без прохода — сообщение; через час повторно не пишем, через 6 часов напоминаем. + const later = new Date(now.getTime() + 20 * MIN); + await runParserWatchdog(t.db, state, later); + expect((await sent(admin!.id)).at(-1)).toContain('Парсер стоит: не запускался 25 мин'); + await runParserWatchdog(t.db, state, new Date(later.getTime() + 60 * MIN)); + expect(await sent(admin!.id)).toHaveLength(1); + await runParserWatchdog(t.db, state, new Date(later.getTime() + 6 * 60 * MIN)); + expect(await sent(admin!.id)).toHaveLength(2); + + // Парсер снова прошёл — «снова работает». + const back = new Date(later.getTime() + 7 * 60 * MIN); + await t.db.insert(parserRuns).values({ startedAt: back, status: 'ok' }); + await runParserWatchdog(t.db, state, back); + expect((await sent(admin!.id)).at(-1)).toContain('снова работает'); + + // Выключили в настройках: первые 20 минут молчим, потом пишем. + await saveParserSettings(t.db, { enabled: false }); + await t.db.update(settings).set({ updatedAt: back }).where(eq(settings.key, 'parser')); + await t.db.insert(parserRuns).values({ startedAt: new Date(back.getTime() + 10 * MIN), status: 'ok' }); + await runParserWatchdog(t.db, state, new Date(back.getTime() + 10 * MIN)); + expect(await sent(admin!.id)).toHaveLength(3); + await runParserWatchdog(t.db, state, new Date(back.getTime() + 30 * MIN)); + expect((await sent(admin!.id)).at(-1)).toContain('выключен в настройках (уже 30 мин)'); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..106b92f --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "allowImportingTsExtensions": true, "types": ["node"] }, "include": ["src", "drizzle.config.ts"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..801ffbc --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vitest/config'; + +// Интеграционные тесты ходят в Postgres; при параллельном прогоне (deploy.sh) 5 секунд по умолчанию не всегда хватает. +export default defineConfig({ test: { testTimeout: 15_000 } }); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..4829640 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + Y&P Lead Radar + + + + + + +

+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..017a910 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@lr/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@lr/shared": "workspace:*", + "@tanstack/react-query": "^5.104.1", + "lucide-react": "^1.53.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router": "^8.4.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "tailwindcss": "^4.3.3", + "vite": "^8.3.4" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..7276f6c --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..40f0f27 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,110 @@ +import { Navigate, Route, Routes, useLocation, useSearchParams } from 'react-router'; +import { SECTIONS, Shell } from './components/Shell.tsx'; +import { useMe } from './hooks.ts'; +import { useLang } from './i18n.tsx'; +import { AdminPage } from './pages/Admin.tsx'; +import { ForgotPasswordPage, LoginPage, nextPath, ResetPasswordPage, VerifyEmailPage } from './pages/AuthPages.tsx'; +import { InvitePage, OnboardingPage } from './pages/Onboarding.tsx'; +import { SettingsPage } from './pages/Settings.tsx'; +import { BillingPage } from './account/BillingPage.tsx'; +import { InvoicesPage } from './account/InvoicesPage.tsx'; +import { MessagesPage } from './account/MessagesPage.tsx'; +import { HelpPage, SupportPage } from './account/SupportPage.tsx'; +import { TelegramOnlyPage } from './account/TelegramOnly.tsx'; +import { Wizard } from './account/Wizard.tsx'; +import { TeamPage } from './pages/Team.tsx'; +import { ShowcasePage } from './public/ShowcasePage.tsx'; +import { AnalyticsPage } from './crm/AnalyticsPage.tsx'; +import { ArchivePage } from './crm/ArchivePage.tsx'; +import { ClientsPage } from './crm/ClientsPage.tsx'; +import { FiltersPage } from './crm/FiltersPage.tsx'; +import { LeadsPage } from './crm/LeadsPage.tsx'; +import { ListingsPage } from './crm/ListingsPage.tsx'; +import { ParserPage } from './crm/ParserPage.tsx'; + +export function App() { + const { t } = useLang(); + const me = useMe(); + const location = useLocation(); + const [params] = useSearchParams(); + + if (me.isPending) return

{t.common.loading}

; + const user = me.data; + + // Доступно без входа и до создания компании. + const open = ( + <> + } /> + } /> + } /> + {/* Страница подборки для клиента: <компания>/<код>. Адреса разделов кабинета точнее и побеждают её. */} + } /> + + ); + + if (!user) { + return ( + + {open} + } /> + } /> + } /> + } /> + + ); + } + + // Только что вошёл: возвращаем туда, откуда пришёл (например, на страницу приглашения). + if (['/login', '/register', '/forgot-password'].includes(location.pathname)) { + return ; + } + + if (!user.company && !user.user.isSuperadmin) { + return ( + + {open} + } /> + + ); + } + + // Мастер первой настройки — один раз, когда доступ уже открыт и рабочие разделы доступны. + const canWork = user.company && !user.webBlocked && (user.company.access === 'active' || user.company.access === 'overdue'); + if (canWork && !user.user.onboarded && !user.impersonating) { + return ( + + {open} + } /> + + ); + } + + const visible = SECTIONS.filter((s) => s.visible(user)); + const allowed = (path: string) => visible.some((s) => s.path === path); + const home = visible[0]?.path ?? '/settings'; + + return ( + + {open} + }> + {allowed('/leads') && } />} + {allowed('/clients') && } />} + {allowed('/archive') && } />} + {allowed('/analytics') && } />} + {allowed('/filters') && } />} + {allowed('/listings') && } />} + {allowed('/parser') && } />} + {allowed('/team') && } />} + {allowed('/billing') && } />} + {allowed('/admin') && } />} + {allowed('/invoices') && } />} + {allowed('/messages') && } />} + {allowed('/support') && } />} + {allowed('/help') && } />} + {allowed('/telegram') && } />} + } /> + } /> + + + ); +} diff --git a/apps/web/src/account/BillingPage.tsx b/apps/web/src/account/BillingPage.tsx new file mode 100644 index 0000000..b457021 --- /dev/null +++ b/apps/web/src/account/BillingPage.tsx @@ -0,0 +1,301 @@ +import { PLAN_SPECS, PLANS, type BillingModel, type Plan } from '@lr/shared'; +import { useQuery } from '@tanstack/react-query'; +import { Fragment, useState, type ReactNode } from 'react'; +import { api, type Billing, type Invoice, type Me, type PercentDeal } from '../api.ts'; +import { AccessChip, ErrorNote, PageTitle } from '../components/ui.tsx'; +import { Dialog } from '../crm/parts.tsx'; +import { useAction } from '../hooks.ts'; +import { useAcc } from './i18n.ts'; + +const LOCALES = { ru: 'ru-RU', hy: 'hy-AM', en: 'en-GB' } as const; + +export const INVOICE_TONE = { + pending: 'bg-warn/15 text-warn', + paid: 'bg-capture/15 text-capture', + canceled: 'bg-raised text-muted', +} as const; + +/** «За что» счёт: вид и период. */ +export function useInvoiceLabel() { + const { a, date } = useAcc(); + return (i: Pick) => { + const period = + i.periodStart && i.periodEnd ? a.bill.period(date(i.periodStart), date(i.periodEnd)) : i.kind === 'fixed' ? a.bill.firstMonth : ''; + return [a.bill.kind[i.kind], period].filter(Boolean).join(', '); + }; +} + +/** Расшифровка счёта по комиссии: сделки, курс, сумма. */ +export function DealsTable({ deals, rates }: { deals: PercentDeal[]; rates?: Record }) { + const { a, lang, usd } = useAcc(); + const d = a.bill.deal; + const shortDate = (day: string) => new Date(day).toLocaleDateString(LOCALES[lang], { day: 'numeric', month: 'short' }); + if (!deals.length) return

{a.bill.noDeals}

; + const used = [...new Set(deals.map((x) => x.currency))].filter((c) => c !== 'USD' && rates?.[c]); + return ( +
+ + + + {[d.date, d.title, d.agent, d.commission, d.usd].map((h, i) => ( + + ))} + + + + {deals.map((x) => ( + + + + + + + + ))} + +
2 ? 'text-right' : ''} ${i === 2 ? 'hidden sm:table-cell' : ''}`}>{h}
{shortDate(x.date)}{x.title || '—'}{x.agent}{x.agent}{x.commission.toLocaleString('ru-RU')} {x.currency}{usd(x.usd)}
+ {used.length > 0 &&

{used.map((c) => a.bill.rate(c, rates![c]!)).join(' · ')}

} +
+ ); +} + +function PlanDialog({ bill, members, onClose }: { bill: Billing; members: number; onClose: () => void }) { + const { a, t, date } = useAcc(); + const b = a.bill; + const [plan, setPlan] = useState(bill.planned?.plan ?? bill.plan); + const [model, setModel] = useState(bill.planned?.billingModel ?? bill.billingModel); + const spec = PLAN_SPECS[plan]; + const billingModel = spec.commissionPercent === null ? 'fixed' : model; + const save = useAction(() => api('/billing/plan', { method: 'POST', body: { plan, billingModel } })); + const same = plan === bill.plan && billingModel === bill.billingModel; + const setupDue = spec.setupFeeUsd > 0 && PLAN_SPECS[bill.plan].setupFeeUsd === 0; + + return ( + +
{ e.preventDefault(); save.mutate(undefined, { onSuccess: onClose }); }}> +
+ {t.billing.plan} + {PLANS.map((p) => { + const s = PLAN_SPECS[p]; + const active = plan === p; + const tooSmall = members > s.maxMembers; + return ( + + ); + })} +
+
+ {t.billing.model} +
+ {(['fixed', 'percent'] as const).map((m) => { + const disabled = m === 'percent' && spec.commissionPercent === null; + return ( + + ); + })} +
+
+

+ {same ? b.sameAsNow : bill.changeFrom ? b.effectFrom(date(bill.changeFrom)) : b.effectNow} + {!same && setupDue && ` ${b.setupNote(spec.setupFeeUsd)}`} +

+ +
+ + +
+ +
+ ); +} + +export function BillingPage({ me }: { me: Me }) { + const { a, t, date, usd } = useAcc(); + const b = a.bill; + const label = useInvoiceLabel(); + const bill = useQuery({ queryKey: ['billing'], queryFn: () => api('/billing') }); + const cancelPlanned = useAction(() => api('/billing/plan', { method: 'DELETE' })); + const pay = useAction((id: number) => api<{ url: string }>(`/invoices/${id}/pay`, { method: 'POST' }), ['none']); + const [dialog, setDialog] = useState(false); + const [open, setOpen] = useState(null); + const c = me.company!; + const data = bill.data; + if (!data) return <>; + + const spec = PLAN_SPECS[data.plan]; + const modelName = (m: string, p: Plan) => + m === 'fixed' ? `${t.onboarding.fixed} — $${PLAN_SPECS[p].monthlyUsd}` : `${t.onboarding.percent} — ${PLAN_SPECS[p].commissionPercent}%`; + const rows: [string, ReactNode][] = [ + [t.billing.plan, t.plans[data.plan]], + [t.billing.model, modelName(data.billingModel, data.plan)], + [t.billing.status, ], + [t.billing.until, date(data.subscriptionEndsAt)], + [t.billing.members, `${c.members} ${t.common.of} ${spec.maxMembers}`], + ]; + const hasPending = data.invoices.some((i) => i.status === 'pending'); + + return ( + <> + + + + + +
+
+
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ {data.planned && ( +
+ {b.planned(t.plans[data.planned.plan], modelName(data.planned.billingModel, data.planned.plan), date(data.planned.from))} + +
+ )} +
+ + {data.preview && ( +
+

{b.month}

+

{b.monthSub(date(data.preview.from), date(data.preview.to))}

+
+
+
{b.commissions}
+
{usd(data.preview.total)}
+
+
+
{b.toPay(data.preview.percent)}
+
{usd(data.preview.amount)}
+
+
+
+ +
+
+ )} +
+ +
+

{b.invoices}

+ {hasPending && !data.onlinePayments && ( +

{b.payHint}

+ )} + {data.invoices.length === 0 ? ( +

{b.noInvoices}

+ ) : ( + <> + {/* Телефон и Mini App: счета карточками, без горизонтальной прокрутки. */} +
    + {data.invoices.map((i) => ( +
  • +
    +

    {label(i)}

    +

    {usd(i.amount)}

    +
    +

    + {b.status[i.status]} + № {i.id} · {date(i.status === 'paid' ? i.paidAt : i.createdAt)} +

    + {(i.kind === 'percent' || (i.status === 'pending' && data.onlinePayments)) && ( +
    + {i.kind === 'percent' && ( + + )} + {i.status === 'pending' && data.onlinePayments && ( + + )} +
    + )} + {open === i.id && ( +
    + +
    + )} +
  • + ))} +
+
+ + + + {[b.col.num, b.col.date, b.col.what, b.col.amount, b.col.status, ''].map((h, i) => ( + + ))} + + + + {data.invoices.map((i) => ( + + + + + + + + + + {open === i.id && ( + + + + )} + + ))} + +
{h}
{i.id}{date(i.createdAt)}{label(i)}{usd(i.amount)} + {b.status[i.status]} + {i.status === 'paid' && {date(i.paidAt)}} + +
+ {i.kind === 'percent' && ( + + )} + {i.status === 'pending' && data.onlinePayments && ( + + )} +
+
+ +
+
+ + )} +
+ + {dialog && setDialog(false)} />} + + ); +} diff --git a/apps/web/src/account/BotStart.tsx b/apps/web/src/account/BotStart.tsx new file mode 100644 index 0000000..a0dd203 --- /dev/null +++ b/apps/web/src/account/BotStart.tsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query'; +import { Paperclip } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { api, ApiError } from '../api.ts'; +import { ErrorNote } from '../components/ui.tsx'; +import { useAction } from '../hooks.ts'; +import { useAcc } from './i18n.ts'; + +const LANGS = ['ru', 'hy', 'en'] as const; +type Texts = Record<(typeof LANGS)[number], string>; +interface BotStart { text: Texts; files: { fileId: string; kind: 'photo' | 'file'; name: string }[] } + +const MAX_FILE = 20 * 1024 * 1024; +const KEY = ['admin', 'bot-start']; + +/** Приветствие бота: текст по языкам и файлы, которые он присылает в ответ на /start. */ +export function BotStartSettings() { + const { a, t } = useAcc(); + const b = a.bot; + const data = useQuery({ queryKey: KEY, queryFn: () => api('/admin/bot-start') }); + const [text, setText] = useState(null); + useEffect(() => { if (data.data && !text) setText(data.data.text); }, [data.data, text]); + const picker = useRef(null); + const save = useAction(() => api('/admin/bot-start', { method: 'PATCH', body: { text } }), KEY); + // Файл уходит телом запроса как есть — так же, как вложения в поддержке. + const upload = useAction(async (file: File) => { + if (file.size > MAX_FILE) throw new ApiError(400, 'FILE_TOO_LARGE'); + const query = new URLSearchParams({ name: file.name, type: file.type || 'application/octet-stream' }); + const res = await fetch(`/api/admin/bot-start/files?${query}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/octet-stream' }, body: file }); + if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { code?: string }).code ?? 'INTERNAL'); + return res.json(); + }, KEY); + const remove = useAction((index: number) => api(`/admin/bot-start/files/${index}`, { method: 'DELETE' }), KEY); + const files = data.data?.files ?? []; + return ( +
+

{b.title}

+

{b.sub}

+ + {text && ( +
+
{ e.preventDefault(); save.mutate(); }}> + {LANGS.map((lang) => ( +
+ +