diff --git a/app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php b/app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php new file mode 100644 index 00000000..b3548c67 --- /dev/null +++ b/app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php @@ -0,0 +1,97 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('campaign_id')->constrained('client_tg_campaigns')->cascadeOnDelete(); + $table->string('mode', 16); // draft | live + $table->jsonb('payload'); // текст, ссылка, смета — БЕЗ номеров + $table->string('status', 16)->default('queued'); // queued → taken → done | failed + $table->unsignedSmallInteger('attempts')->default(0); + $table->jsonb('result')->nullable(); // сырой ответ робота + $table->string('failure_reason', 1024)->nullable(); + $table->timestamp('taken_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'campaign_id']); + $table->index('status'); + }); + + DB::statement('ALTER TABLE client_tg_robot_jobs ENABLE ROW LEVEL SECURITY'); + DB::statement('ALTER TABLE client_tg_robot_jobs FORCE ROW LEVEL SECURITY'); + DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_robot_jobs'); + DB::statement("CREATE POLICY tenant_isolation ON client_tg_robot_jobs USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)"); + + // Гранты — под гардом на существование роли: на dev/тестах ходит суперпользователь + // postgres, ролей там нет, и голый GRANT уронил бы миграцию. + DB::statement(<<<'SQL' + DO $$ + BEGIN + -- Портал ставит задания при запуске кампании. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + GRANT SELECT, INSERT, UPDATE ON client_tg_robot_jobs TO crm_app_user; + END IF; + + -- Канал робота работает под crm_admin_user (посредник admin-db). + -- Заданий робот не создаёт — только берёт и отчитывается: INSERT ему не даём. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN + GRANT SELECT, UPDATE ON client_tg_robot_jobs TO crm_admin_user; + END IF; + END + $$; + SQL); + + // Нумератор — отдельный объект со своими правами. Без USAGE на нём INSERT падает + // на бою с «permission denied for sequence», а на dev дырка невидима. + DB::statement(<<<'SQL' + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN + IF EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'client_tg_robot_jobs_id_seq' AND c.relkind = 'S' AND n.nspname = 'public' + ) THEN + GRANT USAGE, SELECT ON SEQUENCE public.client_tg_robot_jobs_id_seq TO crm_app_user; + END IF; + END IF; + END + $$; + SQL); + } + + public function down(): void + { + Schema::dropIfExists('client_tg_robot_jobs'); + } +}; diff --git a/app/tests/Feature/ClientTg/RobotJobsTableTest.php b/app/tests/Feature/ClientTg/RobotJobsTableTest.php new file mode 100644 index 00000000..2722bd32 --- /dev/null +++ b/app/tests/Feature/ClientTg/RobotJobsTableTest.php @@ -0,0 +1,26 @@ +toBeTrue(); + + foreach ([ + 'id', 'tenant_id', 'campaign_id', 'mode', 'payload', 'status', + 'attempts', 'result', 'failure_reason', 'taken_at', 'finished_at', + ] as $column) { + expect(Schema::hasColumn('client_tg_robot_jobs', $column))->toBeTrue("нет столбца {$column}"); + } +}); + +it('на таблице включена построчная защита с политикой по тенанту', function () { + $rls = DB::selectOne("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'client_tg_robot_jobs'"); + expect($rls->relrowsecurity)->toBeTrue(); + expect($rls->relforcerowsecurity)->toBeTrue(); + + $policy = DB::selectOne("SELECT polname FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid WHERE c.relname = 'client_tg_robot_jobs'"); + expect($policy->polname)->toBe('tenant_isolation'); +}); diff --git a/cspell-words.txt b/cspell-words.txt index dbf16fc4..0e366abe 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2515,3 +2515,5 @@ otkaz otkaza razvernuto zakryt +гардом +тенантный diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index a08bb84e..f7915952 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -10,6 +10,41 @@ > параллельно с боевым main. Их прежние номера (v8.86–v8.95) **столкнулись** с боевыми (рекламный > кошелёк и Яндекс-канал), поэтому при сведении они перенумерованы. Содержание не менялось. +## v9.28 (2026-07-31) — Telegram-модуль: очередь заданий роботу кабинета МТС + +Портал перестаёт запускать браузер-робота у себя на машине и начинает класть ему задание в таблицу — +робот приходит за ним сам по служебному каналу. Причина: у МТС нет программного интерфейса, кампанию +доводит браузер, а он физически не может жить там же, где очередь портала (МТС отбивает адреса +дата-центров, замер 30.07.2026, 4 прогона). Схема списана с робота креативов Яндекса (`ad_creative_jobs`). + +- **Новая таблица** `client_tg_robot_jobs` (миграция + `app/database/migrations/2026_07_31_100000_create_client_tg_robot_jobs.php`): + - `id`, `tenant_id` → `tenants` (cascade), `campaign_id` → `client_tg_campaigns` (cascade); + - `mode` (`draft` | `live`), `payload` jsonb — состав объявления **без номеров телефонов**; + - `status` (`queued` → `taken` → `done` | `failed`), `attempts`, `result` jsonb, `failure_reason`, + `taken_at`, `finished_at`, `timestamps`; + - индексы: `(tenant_id, campaign_id)` и `status`. +- **RLS:** `ENABLE` + `FORCE ROW LEVEL SECURITY`, политика `tenant_isolation` по + `current_setting('app.current_tenant_id')` — как у остальных `client_tg_*`. +- **GRANT:** `crm_app_user` — SELECT/INSERT/UPDATE (портал ставит задания) плюс `USAGE, SELECT` на + нумератор `client_tg_robot_jobs_id_seq`; `crm_admin_user` — SELECT/UPDATE (канал робота берёт и + отчитывается, заданий не создаёт, поэтому INSERT ему не даётся). Оба GRANT под гардом на + существование роли: на dev/тестах ходит суперпользователь `postgres`, ролей там нет. + +🔴 **При выкате на бой ПЕРЕзапустить `db/03_service_bypass_policies.sql`.** На боевом кластере служебные +роли НЕ BYPASSRLS — кросс-тенантный доступ им даёт именно этот файл. Без повторного прогона канал робота +увидит в новой таблице ноль строк молча, без единой ошибки. Класс ошибки уже ловили 25.07.2026. + +🔴 **ПДн:** номера телефонов в `payload` не кладутся — `payload` попадает в журналы. Номера робот +забирает отдельным запросом `/api/tg-robot/jobs/{id}/phones`, только пока задание в работе, без кеша. + +**rls-reviewer:** прогнан 31.07.2026 — по самой таблице чисто (RLS, политика, гранты, нумератор +совпадают с образцом `ad_creative_jobs`). Дописывать таблицу в `db/03_service_bypass_policies.sql` не +надо: файл идёт циклом по всем таблицам с построчной защитой и подхватит её сам — нужен именно повторный +ПРОГОН файла на бою. 🔴 Отдельная находка ревьюера, к этой таблице не относящаяся, но на том же выкате: +у `crm_admin_user` на `client_tg_campaigns` был только SELECT, а канал робота пишет туда итог — закрыто +миграцией `2026_07_31_100100_grant_admin_update_client_tg_campaigns.php` (см. v9.29). + ## v9.27 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон) Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки