diff --git a/README.md b/README.md index c623871..5af6af4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ pnpm dev:web # http://localhost:5174 боте или кнопка в разделе «Клиенты»). Соискатель отвечает на вопросы — в CRM появляется клиент, закреплённый за агентом, а агент получает в бот вопрос «Показать подходящие объекты?». Телефон обязателен: пропустить нельзя, номер отправляется кнопкой «Отправить мой номер» или пишется вручную. +- **Чат с клиентом** (карточка клиента): переписка с клиентом, заполнившим анкету, через клиентский бот. + Пишут ответственный агент и руководитель — текст, фото и файлы; клиент видит ответ с именем агента. + Сообщения клиента попадают в чат (фото — ещё и в фото карточки), агенту приходит уведомление в основной + бот с кнопкой «Открыть»; в списке клиентов — счётчик непрочитанных. Вложения хранятся у Telegram. - **Страница подборки для клиента**: `<кабинет>/<компания>/<код>` — объекты, прикреплённые к клиенту, и запись на показ по дням. Адрес и ссылка на List.am на странице скрыты. Запись на показ приходит создателю ссылки в бот и появляется в карточке клиента. diff --git a/apps/api/drizzle/0019_strange_ultimates.sql b/apps/api/drizzle/0019_strange_ultimates.sql new file mode 100644 index 0000000..41abedd --- /dev/null +++ b/apps/api/drizzle/0019_strange_ultimates.sql @@ -0,0 +1,18 @@ +CREATE TABLE "client_messages" ( + "id" serial PRIMARY KEY NOT NULL, + "client_id" integer NOT NULL, + "company_id" integer NOT NULL, + "direction" text NOT NULL, + "author_id" integer, + "text" text, + "file_id" text, + "file_kind" text, + "file_name" text, + "read_at" timestamp with time zone, + "at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "client_messages" ADD CONSTRAINT "client_messages_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_messages" ADD CONSTRAINT "client_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 "client_messages" ADD CONSTRAINT "client_messages_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "client_messages_client_idx" ON "client_messages" USING btree ("client_id","id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0019_snapshot.json b/apps/api/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..25bfe05 --- /dev/null +++ b/apps/api/drizzle/meta/0019_snapshot.json @@ -0,0 +1,4543 @@ +{ + "id": "35a5151e-e269-4d0a-bb0d-3a603ade914a", + "prevId": "0e0e3d8c-d9c3-4712-b630-aae9d0a098f2", + "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_messages": { + "name": "client_messages", + "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 + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_messages_client_idx": { + "name": "client_messages_client_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_messages_client_id_clients_id_fk": { + "name": "client_messages_client_id_clients_id_fk", + "tableFrom": "client_messages", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_messages_company_id_companies_id_fk": { + "name": "client_messages_company_id_companies_id_fk", + "tableFrom": "client_messages", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_messages_author_id_users_id_fk": { + "name": "client_messages_author_id_users_id_fk", + "tableFrom": "client_messages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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 index 106e289..e5f0d1e 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1792954204010, "tag": "0018_elite_magdalene", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1792969839044, + "tag": "0019_strange_ultimates", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7083796..676876b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -76,6 +76,8 @@ 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 { registerClientChatRoutes, telegramClientDownload, type ClientBot } from './clients/chat.ts'; +import { HttpTelegram } from './telegram/client.ts'; import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts'; import { registerBotStartRoutes } from './telegram/start.ts'; import { enqueueText } from './telegram/outbox.ts'; @@ -118,6 +120,8 @@ export interface AppDeps { fetchImage?: ImageFetcher; /** Отправка в группу компании в Telegram; в тестах подменяется. */ groupSender?: GroupSender; + /** Клиентский бот для чата с клиентом из карточки; в тестах подменяется. */ + clientBot?: ClientBot | null; } const Email = z.string().trim().toLowerCase().pipe(z.email()); @@ -128,7 +132,7 @@ 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) { +export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender, clientBot }: AppDeps) { const app = Fastify({ logger: false }); void app.register(cookie); // Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут. @@ -866,7 +870,15 @@ export function buildApp({ db, config, mailer = consoleMailer, source = null, pa }); registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender); - registerClientRoutes(app, db, { member, manager, writable }, config); + // Клиентский бот для чата из карточки клиента: без токена чат выключен. + const chatBot: ClientBot | null = + clientBot !== undefined + ? clientBot + : config.clientBotToken + ? { tg: new HttpTelegram(config.clientBotToken), sendFile: telegramFileSender(config.clientBotToken), download: telegramClientDownload(config.clientBotToken) } + : null; + registerClientRoutes(app, db, { member, manager, writable }, config, chatBot); + registerClientChatRoutes(app, db, { member, manager, writable }, chatBot); registerDepartmentRoutes(app, db, { member, manager, writable }); registerPipelineRoutes(app, db, { member, manager, writable }); registerArchiveRoutes(app, db, { member, manager, writable }); diff --git a/apps/api/src/clients/chat.ts b/apps/api/src/clients/chat.ts new file mode 100644 index 0000000..f3ee624 --- /dev/null +++ b/apps/api/src/clients/chat.ts @@ -0,0 +1,177 @@ +import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Db } from '../db/client.ts'; +import { clientMessages, intakeSessions, users } from '../db/schema.ts'; +import { intakeTexts } from '../intake/texts.ts'; +import type { Guards, MemberCtx } from '../leads/routes.ts'; +import { badRequest, conflict, forbidden } from '../lib/errors.ts'; +import type { FileSender } from '../support/service.ts'; +import { TelegramError, type TelegramApi } from '../telegram/client.ts'; +import { esc } from '../telegram/texts.ts'; +import { loadClient } from './service.ts'; + +// Чат с клиентом, оставившим анкету в клиентском боте. Пишут ответственный агент и руководитель — из +// карточки клиента в CRM; клиент получает ответ в клиентском боте с именем агента. Сообщения клиента +// приходят в intake/service.ts (receiveClientMessage). Вложения хранятся у Telegram клиентского бота. + +/** Клиентский бот: отправка текста и файлов, скачивание вложений по их идентификатору. */ +export interface ClientBot { + tg: TelegramApi; + sendFile: FileSender; + /** Файл по идентификатору Telegram; null — не нашёлся. */ + download: (fileId: string) => Promise<{ data: Buffer; type: string | null; path: string } | null>; +} + +/** Скачивание вложения клиентского бота через Bot API (токен наружу не уходит). */ +export const telegramClientDownload = (token: string): ClientBot['download'] => async (fileId) => { + const base = 'https://api.telegram.org'; + const info = (await (await fetch(`${base}/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as { + ok: boolean; + result?: { file_path?: string }; + }; + if (!info.ok || !info.result?.file_path) return null; + const file = await fetch(`${base}/file/bot${token}/${info.result.file_path}`); + if (!file.ok) return null; + return { data: Buffer.from(await file.arrayBuffer()), type: file.headers.get('content-type'), path: info.result.file_path }; +}; + +const Id = z.object({ id: z.coerce.number().int().positive() }); +const MAX_FILE = 20 * 1024 * 1024; + +/** Чат клиента в клиентском боте: последняя анкета, привязанная к карточке. */ +export async function clientChat(db: Db, clientId: number) { + 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; +} + +/** Сколько сообщений клиента ещё не прочитано ответственным. */ +export const unreadMessages = sql`(select count(*)::int from client_messages m where m.client_id = "clients"."id" and m.direction = 'in' and m.read_at is null)`; + +export function registerClientChatRoutes(app: FastifyInstance, db: Db, g: Guards, bot: ClientBot | null) { + /** Читать и писать в чат могут ответственный агент и руководитель — как и видеть контакты клиента. */ + const own = async (ctx: MemberCtx, id: number) => { + const client = await loadClient(db, ctx, id); + if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT'); + return client; + }; + + /** Куда отправлять: чат клиента и подпись «Имя, агент». */ + const target = async (ctx: MemberCtx, clientId: number) => { + if (!bot) throw badRequest('CLIENT_BOT_DISABLED'); + const chat = await clientChat(db, clientId); + if (!chat) throw conflict('NO_CLIENT_CHAT'); + return { chat, signature: `${esc(ctx.user.name)}, ${esc(intakeTexts(chat.lang).agent)}:` }; + }; + + /** Клиент заблокировал бота — понятная ошибка вместо «что-то пошло не так». */ + const delivered = async (send: () => Promise): Promise => { + try { + return await send(); + } catch (error) { + if (error instanceof TelegramError && error.code === 403) throw conflict('CLIENT_BLOCKED_BOT'); + if (error instanceof TelegramError && error.code === 400) throw conflict('CLIENT_CHAT_UNAVAILABLE'); + throw error; + } + }; + + app.get('/api/clients/:id/messages', async (req) => { + const ctx = g.member(req); + const { id } = Id.parse(req.params); + const client = await own(ctx, id); + // Сообщения клиента прочитаны, когда чат открыл ответственный (руководитель, заглянув, их не «съедает»). + if (client.assigneeId === ctx.user.id) { + await db + .update(clientMessages) + .set({ readAt: new Date() }) + .where(and(eq(clientMessages.clientId, id), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt))); + } + const messages = await db + .select({ + id: clientMessages.id, + direction: clientMessages.direction, + text: clientMessages.text, + fileKind: clientMessages.fileKind, + fileName: clientMessages.fileName, + authorName: users.name, + at: clientMessages.at, + }) + .from(clientMessages) + .leftJoin(users, eq(users.id, clientMessages.authorId)) + .where(eq(clientMessages.clientId, id)) + .orderBy(asc(clientMessages.id)) + .limit(500); + return { available: Boolean(bot && (await clientChat(db, id))), messages }; + }); + + app.post('/api/clients/:id/messages', async (req) => { + const ctx = g.writable(g.member(req)); + const { id } = Id.parse(req.params); + const { text } = z.object({ text: z.string().trim().min(1).max(3500) }).parse(req.body); + const client = await own(ctx, id); + const { chat, signature } = await target(ctx, id); + await delivered(() => bot!.tg.sendMessage(chat.chatId, `${signature}\n${esc(text)}`)); + const [m] = await db.insert(clientMessages).values({ clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id, text }).returning(); + return { id: m!.id }; + }); + + /** Файл или фото: имя, тип и подпись — в адресе, сам файл — в теле запроса. */ + app.post('/api/clients/:id/messages/file', { bodyLimit: MAX_FILE }, async (req) => { + const ctx = g.writable(g.member(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 (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION'); + const client = await own(ctx, id); + const { chat, signature } = await target(ctx, id); + const caption = [signature, q.text ? esc(q.text) : ''].filter(Boolean).join('\n'); + const sent = await delivered(() => bot!.sendFile(chat.chatId, { name: q.name, type: q.type, data: req.body as Buffer }, caption)); + const [m] = await db + .insert(clientMessages) + .values({ + clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id, + text: q.text || null, fileId: sent.fileId, fileKind: sent.kind, fileName: q.name, + }) + .returning(); + return { id: m!.id }; + }); + + /** Вложение из чата: файл лежит у Telegram клиентского бота. */ + app.get('/api/clients/:id/messages/:messageId/file', async (req, reply) => { + const ctx = g.member(req); + const { id, messageId } = z.object({ id: z.coerce.number().int().positive(), messageId: z.coerce.number().int().positive() }).parse(req.params); + await own(ctx, id); + const [m] = await db.select().from(clientMessages).where(and(eq(clientMessages.id, messageId), eq(clientMessages.clientId, id))); + if (!m?.fileId || !bot) throw badRequest('FILE_NOT_FOUND'); + const file = await bot.download(m.fileId); + if (!file) throw badRequest('FILE_NOT_FOUND'); + const name = m.fileName ?? file.path.split('/').pop() ?? 'file'; + const type = m.fileKind === 'photo' ? 'image/jpeg' : (file.type ?? 'application/octet-stream'); + return reply + .header('content-type', type) + .header('cache-control', 'private, max-age=3600') + .header('content-disposition', `${m.fileKind === 'photo' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`) + .send(file.data); + }); +} + +/** Для карточки: есть ли чат и сколько непрочитанных. */ +export async function chatSummary(db: Db, clientId: number, bot: ClientBot | null) { + const [row] = await db + .select({ n: sql`count(*)::int` }) + .from(clientMessages) + .where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt))); + const [any] = await db.select({ id: clientMessages.id }).from(clientMessages).where(eq(clientMessages.clientId, clientId)).limit(1); + return { available: Boolean(bot && (await clientChat(db, clientId))), unread: row?.n ?? 0, hasMessages: Boolean(any) }; +} + diff --git a/apps/api/src/clients/routes.ts b/apps/api/src/clients/routes.ts index 1e3b690..bcb30be 100644 --- a/apps/api/src/clients/routes.ts +++ b/apps/api/src/clients/routes.ts @@ -1,3 +1,4 @@ +import { chatSummary, unreadMessages, type ClientBot } from './chat.ts'; 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'; @@ -45,7 +46,7 @@ const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'ima const MAX_PHOTO = 5 * 1024 * 1024; const MAX_PHOTOS = 10; -export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) { +export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, chatBot: ClientBot | null = null) { type Ctx = ReturnType; const actor = (ctx: Ctx): Actor => ctx; @@ -102,6 +103,7 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co assigneeName: users.name, departmentName: departments.name, leadsCount: sql`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`, + unreadMessages, }) .from(clients) .leftJoin(users, eq(users.id, clients.assigneeId)) @@ -109,7 +111,11 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co .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 })); + return rows.map((r) => ({ + ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount, + // Непрочитанные сообщения клиента в чате — тем, кто видит чат (ответственному и руководителю). + unreadMessages: ctx.role === 'manager' || r.client.assigneeId === ctx.user.id ? r.unreadMessages : 0, + })); }); app.get('/api/clients-summary', async (req) => { @@ -194,6 +200,8 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co /** Страница подборки для клиента, если ссылка создана. */ pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null, viewings: await clientViewings(db, id), + /** Чат с клиентом в клиентском боте — ответственному и руководителю. */ + chat: ctx.role === 'manager' || client.assigneeId === ctx.user.id ? await chatSummary(db, id, chatBot) : null, leads: linked, deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null, events: history.map(({ event: e, userName }) => ({ diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index b61f1fc..bde0ccb 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -396,6 +396,33 @@ export const clientPhotos = pgTable( (t) => [index('client_photos_client_idx').on(t.clientId)], ); +/** + * Чат с клиентом, оставившим анкету в клиентском боте. in — клиент написал в бот, out — ответ агента + * или руководителя из карточки клиента. Вложения хранятся у Telegram (клиентского бота): здесь только + * идентификатор файла. + */ +export const clientMessages = pgTable( + 'client_messages', + { + id: serial('id').primaryKey(), + clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }), + companyId: integer('company_id').notNull().references(() => companies.id), + /** in | out */ + direction: text('direction').notNull(), + /** Кто ответил (для out). */ + authorId: integer('author_id').references(() => users.id), + text: text('text'), + fileId: text('file_id'), + /** photo | file */ + fileKind: text('file_kind'), + fileName: text('file_name'), + /** Когда ответственный прочитал сообщение клиента (для in). */ + readAt: ts('read_at'), + at: ts('at').notNull().defaultNow(), + }, + (t) => [index('client_messages_client_idx').on(t.clientId, t.id)], +); + /** Объекты, предложенные или показанные клиенту. */ export const clientLeads = pgTable( 'client_leads', diff --git a/apps/api/src/intake/service.ts b/apps/api/src/intake/service.ts index a54fa86..7a2899b 100644 --- a/apps/api/src/intake/service.ts +++ b/apps/api/src/intake/service.ts @@ -2,7 +2,7 @@ import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type 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 { clientMessages, 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 { REMOVE_KEYBOARD, type Keyboard, type TelegramApi, type TgUpdate } from '../telegram/client.ts'; @@ -256,23 +256,57 @@ async function complete(db: Db, s: Session, a: Answers) { }); } +/** Сообщение клиента в бот после анкеты: текст и (или) вложение — у Telegram клиентского бота. */ +export interface IncomingClientMessage { + text: string | null; + fileId?: string; + fileKind?: 'photo' | 'file'; + fileName?: string | null; +} + +/** Сообщения клиента подряд не подтверждаются каждый раз: «передали агенту» — не чаще раза в 30 минут. */ +const ACK_GAP_MS = 30 * 60_000; + /** - * Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю - * карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот. + * Клиент написал в бот после анкеты — сообщение попадает в чат карточки клиента. Номер телефона из текста + * сохраняется в карточку, если его там нет. Ответственному агенту — уведомление в основной бот с кнопкой + * «Открыть». Возвращает, нужно ли подтвердить клиенту, что сообщение передано. */ -async function saveReply(db: Db, clientId: number, reply: string): Promise { +export async function receiveClientMessage(db: Db, clientId: number, m: IncomingClientMessage): Promise<{ ack: boolean } | null> { 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; + if (!client) return null; + const text = m.text?.trim().slice(0, 3500) || null; + const [prev] = await tx + .select({ at: clientMessages.at }) + .from(clientMessages) + .where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in'))) + .orderBy(desc(clientMessages.id)) + .limit(1); + await tx.insert(clientMessages).values({ + clientId, companyId: client.companyId, direction: 'in', text, + fileId: m.fileId ?? null, fileKind: m.fileKind ?? null, fileName: m.fileName ?? null, + }); + const phone = text && text.replace(/\D/g, '').length >= 8 ? normalizePhone(text) : 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; + const body = [text, m.fileKind === 'photo' ? '📷' : m.fileKind === 'file' ? `📎 ${m.fileName ?? ''}`.trim() : null].filter(Boolean).join('\n'); + const note = [`💬 ${client.name}:`, body, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n'); + await tx.insert(outbox).values({ kind: 'text', userId: client.assigneeId, payload: { raw: note, openPath: `/clients/${clientId}` } }); + return { ack: !prev || Date.now() - prev.at.getTime() > ACK_GAP_MS }; + }); +} + +/** Фото клиента — ещё и в фото карточки (до 10 штук, до 5 МБ). Не вышло — фото всё равно есть в чате. */ +async function addCardPhoto(db: Db, clientId: number, fileId: string, download?: FileDownloader) { + const [client] = await db.select().from(clients).where(eq(clients.id, clientId)); + if (!client) return; + const [{ n } = { n: 0 }] = await db.select({ n: sql`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, clientId)); + if (n >= 10) return; + const file = await download?.(fileId).catch(() => null); + if (!file || file.data.length > 5 * 1024 * 1024) return; + await db.insert(clientPhotos).values({ + clientId, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'), }); } @@ -355,25 +389,22 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat } 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 msg = update.message; + if (!session.clientId || !msg) return void (await tg.sendMessage(chatId, st.after)); + const photo = msg.photo?.at(-1)?.file_id; + const incoming: IncomingClientMessage = { + text: msg.contact?.phone_number ?? msg.text ?? msg.caption ?? null, + ...(photo ? { fileId: photo, fileKind: 'photo' as const } : msg.document ? { fileId: msg.document.file_id, fileKind: 'file' as const, fileName: msg.document.file_name ?? null } : {}), + }; + if (!incoming.text && !incoming.fileId) return void (await tg.sendMessage(chatId, st.after)); + const saved = await receiveClientMessage(db, session.clientId, incoming); + if (!saved) return void (await tg.sendMessage(chatId, st.after)); + if (photo) await addCardPhoto(db, session.clientId, photo, download); + if (saved.ack) await tg.sendMessage(chatId, esc(st.replySaved)); + return; } const step = session.step as Step; diff --git a/apps/api/src/intake/texts.ts b/apps/api/src/intake/texts.ts index 370a4a3..158ea48 100644 --- a/apps/api/src/intake/texts.ts +++ b/apps/api/src/intake/texts.ts @@ -22,7 +22,7 @@ const ru = { skip: 'Пропустить', done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`, photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.', - replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.', + replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.', agent: 'агент', after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.', useButtons: 'Выберите вариант кнопкой под сообщением.', langSet: 'Язык: русский. Сменить язык — команда /lang.', @@ -51,7 +51,7 @@ const hy: Texts = { skip: 'Բաց թողնել', done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`, photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։', - replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։', + replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։', agent: 'գործակալ', after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։', useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։', langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։', @@ -78,7 +78,7 @@ const en: Texts = { 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.', + replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.', agent: 'agent', 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.', diff --git a/apps/api/src/leads/routes.ts b/apps/api/src/leads/routes.ts index 3ce480b..596611c 100644 --- a/apps/api/src/leads/routes.ts +++ b/apps/api/src/leads/routes.ts @@ -43,7 +43,7 @@ import { type Lead, } from './service.ts'; -type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState }; +export type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState }; export interface Guards { member: (req: FastifyRequest) => MemberCtx; diff --git a/apps/api/src/showcase.int.test.ts b/apps/api/src/showcase.int.test.ts index 630c845..9197751 100644 --- a/apps/api/src/showcase.int.test.ts +++ b/apps/api/src/showcase.int.test.ts @@ -1,3 +1,4 @@ +import { TelegramError } from './telegram/client.ts'; import { and, eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { hashPassword } from './auth/password.ts'; @@ -171,19 +172,59 @@ describe('анкета в клиентском боте', () => { 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'); + fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }], caption: 'Это мы' } })); 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'); + expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1); + // Второе сообщение подряд клиенту не подтверждается — бот не засоряет переписку. + const repliesBefore = seekerBot.inbox(seeker).length; + await say(seeker, 'Удобно звонить после 18:00'); + expect(seekerBot.inbox(seeker)).toHaveLength(repliesBefore); + await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config }, + fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, document: { file_id: 'doc1', file_name: 'паспорт.pdf' } } })); await flush(); - expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true); + const note = main.inbox(agent.chat).find((m) => m.text.includes('Удобно звонить после 18:00'))!; + expect(note.text).toContain('💬 Анна:'); + + // Карточка: чат есть, 3 непрочитанных; список клиентов показывает счётчик. + const chatCard = (await agent.api.get(`/api/clients/${client!.id}`)).body; + expect(chatCard.chat).toEqual({ available: true, unread: 3, hasMessages: true }); + expect(((await agent.api.get('/api/clients')).body as { id: number; unreadMessages: number }[]).find((x) => x.id === client!.id)!.unreadMessages).toBe(3); + const chat = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body; + expect(chat.available).toBe(true); + expect(chat.messages.map((m: { direction: string; text: string | null; fileKind: string | null; fileName: string | null }) => [m.direction, m.text, m.fileKind, m.fileName])).toEqual([ + ['in', 'Это мы', 'photo', null], + ['in', 'Удобно звонить после 18:00', null, null], + ['in', null, 'file', 'паспорт.pdf'], + ]); + // Открыл ответственный — прочитано. + expect((await agent.api.get(`/api/clients/${client!.id}`)).body.chat.unread).toBe(0); + // Вложение клиента открывается из CRM. + const docId = chat.messages[2].id; + const file = await agent.api.get(`/api/clients/${client!.id}/messages/${docId}/file`); + expect(file.status).toBe(200); + expect(file.headers['content-disposition']).toContain(encodeURIComponent('паспорт.pdf')); + + // Ответ агента уходит клиенту в клиентский бот с именем агента. + expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Позвоню в 18:30' })).status).toBe(200); + const answer = t.clientBotTg.inbox(seeker).at(-1)!; + expect(answer.text).toBe('Арам, агент:\nПозвоню в 18:30'); + // Файл — тоже, с подписью. + expect((await agent.api.post(`/api/clients/${client!.id}/messages/file?name=plan.pdf&type=application/pdf&text=${encodeURIComponent('План квартиры')}`, Buffer.from('%PDF'))).status).toBe(200); + expect(t.clientBotFiles.at(-1)).toMatchObject({ chatId: seeker, name: 'plan.pdf', caption: 'Арам, агент:\nПлан квартиры' }); + const after = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body.messages; + expect(after.slice(-2).map((m: { direction: string; authorName: string; fileName: string | null }) => [m.direction, m.authorName, m.fileName])).toEqual([ + ['out', 'Арам', null], + ['out', 'Арам', 'plan.pdf'], + ]); + // Клиент заблокировал бота — понятная ошибка. + t.clientBotTg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user')); + expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Алло?' })).body.code).toBe('CLIENT_BLOCKED_BOT'); + // Клиент заведён вручную, анкеты не было — писать некуда. + const manual = (await agent.api.post('/api/clients', { name: 'Без бота', kind: 'buyer', request: {} })).body.id; + expect((await agent.api.get(`/api/clients/${manual}`)).body.chat).toEqual({ available: false, unread: 0, hasMessages: false }); + expect((await agent.api.post(`/api/clients/${manual}/messages`, { text: 'Здравствуйте' })).body.code).toBe('NO_CLIENT_CHAT'); // Автору ссылки в основной бот: сводка и вопрос с кнопками. await flush(); diff --git a/apps/api/src/telegram/outbox.ts b/apps/api/src/telegram/outbox.ts index 4461ca3..db6deba 100644 --- a/apps/api/src/telegram/outbox.ts +++ b/apps/api/src/telegram/outbox.ts @@ -79,10 +79,13 @@ export interface OutboxDeps { sleep?: (ms: number) => Promise; } -const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => { +const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => openPathButton(config, lang, `/leads/${leadId}`); + +/** Кнопка «Открыть» страницу CRM в Mini App. */ +const openPathButton = (config: Config, lang: string, path: string): Keyboard[number] => { // Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет. if (!config.appUrl.startsWith('https://')) return []; - return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }]; + return [{ text: texts(lang).open, webApp: `${config.appUrl}${path}` }]; }; /** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */ @@ -117,7 +120,7 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom 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 p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[]; openPath?: 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 }))] @@ -129,7 +132,8 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom : 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)); + const open = p.openPath ? openPathButton(config, user.lang, p.openPath) : job.leadId ? openButton(config, user.lang, job.leadId) : []; + if (text) await tg.sendMessage(chatId, text, buttons ?? (open.length ? [open] : undefined)); await finish(); } else if (job.kind === 'lead') { const [row] = await db diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts index 158c5ae..bfd376f 100644 --- a/apps/api/src/testing.ts +++ b/apps/api/src/testing.ts @@ -55,18 +55,32 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p 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 clientBotTg = fakeTelegram(); + const clientBotFiles: { chatId: number; name: string; type: string; caption: string }[] = []; + const clientBot = { + tg: clientBotTg.tg, + sendFile: (async (chatId, file, caption) => { + clientBotFiles.push({ chatId, name: file.name, type: file.type, caption }); + return { fileId: `client-file-${clientBotFiles.length}`, kind: file.type.startsWith('image/') ? 'photo' : 'file' }; + }) as FileSender, + download: async (fileId: string) => ({ data: Buffer.from(`file:${fileId}`), type: 'application/pdf', path: `documents/${fileId}.pdf` }), + }; + const app = buildApp({ db, config, source, payments, sendFile, groupSender, clientBot, 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 raw = Buffer.isBuffer(body); const res = await app.inject({ method, url: path, payload: body as object | undefined, - headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}) }, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}), ...(raw ? { 'content-type': 'application/octet-stream' } : {}) }, }); - return { status: res.statusCode, body: res.json() as any }; + let parsed: any; + try { parsed = res.json(); } catch { parsed = res.rawPayload; } + return { status: res.statusCode, body: parsed, headers: res.headers }; }; return { get: (p: string) => call('GET', p), @@ -87,7 +101,7 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p return client(res.body.token as string); }; - return { app, db, config, mails, sentFiles, groupSent, client, register, close: async () => { await app.close(); await close(); } }; + return { app, db, config, mails, sentFiles, groupSent, clientBotTg, clientBotFiles, client, register, close: async () => { await app.close(); await close(); } }; } export interface FakeMessage extends SentMessage { diff --git a/apps/web/src/crm/ClientChat.tsx b/apps/web/src/crm/ClientChat.tsx new file mode 100644 index 0000000..4060716 --- /dev/null +++ b/apps/web/src/crm/ClientChat.tsx @@ -0,0 +1,154 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Paperclip, Send, X } 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 { useCrm } from './i18n.ts'; + +interface ChatMessage { + id: number; + direction: 'in' | 'out'; + text: string | null; + fileKind: 'photo' | 'file' | null; + fileName: string | null; + authorName: string | null; + at: string; +} + +const MAX_FILE = 20 * 1024 * 1024; +const size = (bytes: number) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} КБ` : `${(bytes / 1024 / 1024).toFixed(1)} МБ`); + +/** + * Чат с клиентом из карточки: переписка в боте анкеты. Сообщения клиента слева, ответы агентов справа. + * Открытый чат обновляется сам; у ответственного открытие отмечает сообщения клиента прочитанными. + */ +export function ClientChat({ clientId, canWrite }: { clientId: number; canWrite: boolean }) { + const { c, dateTime } = useCrm(); + const x = c.ch; + const qc = useQueryClient(); + const chat = useQuery({ + queryKey: ['client', clientId, 'messages'], + queryFn: () => api<{ available: boolean; messages: ChatMessage[] }>(`/clients/${clientId}/messages`), + refetchInterval: 5_000, + }); + const [text, setText] = useState(''); + const [file, setFile] = useState(null); + const picker = useRef(null); + const list = useRef(null); + + // Прочитанные сообщения убирают счётчики в карточке и в списке клиентов. + const count = chat.data?.messages.length ?? 0; + const lastIn = chat.data?.messages.filter((m) => m.direction === 'in').at(-1)?.id; + useEffect(() => { + if (lastIn === undefined) return; + void qc.invalidateQueries({ queryKey: ['client', clientId], exact: true }); + void qc.invalidateQueries({ queryKey: ['clients'] }); + }, [lastIn, clientId, qc]); + // Новые сообщения — прокрутка вниз (внутри чата, а не всей карточки). + useEffect(() => { + if (list.current) list.current.scrollTop = list.current.scrollHeight; + }, [count]); + + // Файл уходит телом запроса как есть; текст в этом случае становится подписью к нему. + const send = useAction(async () => { + if (!file) return api(`/clients/${clientId}/messages`, { method: 'POST', body: { text } }); + 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' }); + if (text.trim()) query.set('text', text.trim().slice(0, 900)); + const res = await fetch(`/api/clients/${clientId}/messages/file?${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(); + }, ['client', clientId, 'messages']); + const ready = Boolean(text.trim() || file); + const submit = () => ready && !send.isPending && send.mutate(undefined, { onSuccess: () => { setText(''); setFile(null); } }); + + const d = chat.data; + const fileUrl = (m: ChatMessage) => `/api/clients/${clientId}/messages/${m.id}/file`; + + return ( +
+

{x.title}

+
+ {!d ? ( +
+ ) : ( + <> +
    + {d.messages.length === 0 &&
  1. {d.available ? x.empty : x.unavailable}
  2. } + {d.messages.map((m) => { + const out = m.direction === 'out'; + return ( +
  3. +
    +

    {out ? (m.authorName ?? '—') : x.client}

    + {m.fileKind === 'photo' && ( + + {x.photo} + + )} + {m.fileKind === 'file' && ( + + + )} + {m.text &&

    {m.text}

    } +

    {dateTime(m.at)}

    +
    +
  4. + ); + })} +
+ + {canWrite && d.available && ( +
{ e.preventDefault(); submit(); }}> + + {file && ( +

+

+ )} +
+ { setFile(e.target.files?.[0] ?? null); e.target.value = ''; }} /> + +